Skip to content

Commit 46efa87

Browse files
committed
feat: Active word sliding
1 parent 27017b7 commit 46efa87

File tree

8 files changed

+450
-132
lines changed

8 files changed

+450
-132
lines changed

src/codecs/active-word.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,124 @@ export function interpolateBackgroundPosition(
206206
return activePos;
207207
}
208208

209+
/**
210+
* Slide transition state to track continuous background animation
211+
*/
212+
export type SlideTransitionState = {
213+
// Position we're animating FROM (captured when word changed)
214+
fromPos: { x: number; y: number; width: number };
215+
// Which word we're animating to
216+
toWordIndex: number;
217+
// Animation progress (within the word) when we started this transition
218+
startProgress: number;
219+
};
220+
221+
/**
222+
* Calculate slide animation position with continuous interpolation.
223+
* This function tracks the transition state and smoothly animates the background
224+
* from the previous word position to the current active word position.
225+
*
226+
* @param activePos - Current active word position (target)
227+
* @param activeWordIndex - Index of the current active word
228+
* @param animationProgress - Progress through the current word (0-1)
229+
* @param transitionState - Current transition state
230+
* @param prevActivePos - Position of the PREVIOUS active word (needed when word changes)
231+
* @param slideDuration - Duration of slide as fraction of word duration (default 0.3 = 30%)
232+
* @returns Interpolated position for the background
233+
*/
234+
export function calculateSlidePosition(
235+
activePos: { x: number; y: number; width: number },
236+
activeWordIndex: number,
237+
animationProgress: number,
238+
transitionState: SlideTransitionState | null,
239+
prevActivePos: { x: number; y: number; width: number } | null,
240+
slideDuration: number = 0.3
241+
): {
242+
position: { x: number; y: number; width: number };
243+
newTransitionState: SlideTransitionState | null;
244+
} {
245+
// No active word - return as-is
246+
if (activeWordIndex < 0) {
247+
return { position: activePos, newTransitionState: null };
248+
}
249+
250+
// First word or no previous state - snap to position, no animation
251+
if (!transitionState) {
252+
const newState: SlideTransitionState = {
253+
fromPos: { ...activePos },
254+
toWordIndex: activeWordIndex,
255+
startProgress: 0,
256+
};
257+
return { position: activePos, newTransitionState: newState };
258+
}
259+
260+
// Word has changed - start a new transition
261+
if (transitionState.toWordIndex !== activeWordIndex) {
262+
// Use the previous active position as our starting point
263+
// If we don't have it, use where we currently are based on old transition
264+
let fromPos: { x: number; y: number; width: number };
265+
266+
if (prevActivePos) {
267+
// We have the previous word's position - start from there
268+
fromPos = { ...prevActivePos };
269+
} else {
270+
// Fallback: calculate where we were in the old transition
271+
// This uses the old fromPos as an approximation
272+
fromPos = { ...transitionState.fromPos };
273+
}
274+
275+
const newState: SlideTransitionState = {
276+
fromPos,
277+
toWordIndex: activeWordIndex,
278+
startProgress: animationProgress,
279+
};
280+
281+
// Calculate position for this frame
282+
const position = interpolateSlideProgress(
283+
newState.fromPos,
284+
activePos,
285+
newState.startProgress,
286+
animationProgress,
287+
slideDuration
288+
);
289+
290+
return { position, newTransitionState: newState };
291+
}
292+
293+
// Same word - continue the transition
294+
const position = interpolateSlideProgress(
295+
transitionState.fromPos,
296+
activePos,
297+
transitionState.startProgress,
298+
animationProgress,
299+
slideDuration
300+
);
301+
302+
return { position, newTransitionState: transitionState };
303+
}
304+
305+
/**
306+
* Interpolate between two positions based on animation progress.
307+
*/
308+
function interpolateSlideProgress(
309+
fromPos: { x: number; y: number; width: number },
310+
toPos: { x: number; y: number; width: number },
311+
startProgress: number,
312+
currentProgress: number,
313+
slideDuration: number
314+
): { x: number; y: number; width: number } {
315+
// Calculate how far through the transition we are
316+
const elapsed = currentProgress - startProgress;
317+
const t = Math.min(1, Math.max(0, elapsed / slideDuration));
318+
const easedT = easeOutCubic(t);
319+
320+
return {
321+
x: fromPos.x + (toPos.x - fromPos.x) * easedT,
322+
y: fromPos.y + (toPos.y - fromPos.y) * easedT,
323+
width: fromPos.width + (toPos.width - fromPos.width) * easedT,
324+
};
325+
}
326+
209327
export function calculateScaledBackground(
210328
bgWidth: number,
211329
fontSizePx: number,

src/codecs/subtitle-renderer.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
calculateActualWidth,
2121
interpolateBackgroundPosition,
2222
calculateScaledBackground,
23+
calculateSlidePosition,
24+
SlideTransitionState,
2325
} from "./active-word";
2426

2527
// Word animation data for export
@@ -90,6 +92,8 @@ export type RendererContext = {
9092
// For smooth background animation
9193
prevWordPosition?: { x: number; y: number; width: number };
9294
animatedBgRect?: Konva.Rect;
95+
// For slide animation
96+
slideTransitionState?: SlideTransitionState | null;
9397
};
9498

9599
// monkeypatch Konva for offscreen canvas usage
@@ -352,8 +356,9 @@ function updateWordAnimationLayer(
352356

353357
ctx.wordGroup.destroyChildren();
354358

359+
// Pop scale is only used when enablePop is enabled AND slide is not enabled
355360
let popScale = 1;
356-
if (wordAnim.showPop && activePos) {
361+
if (wordAnim.enablePop && !wordAnim.enableSlide && activePos) {
357362
popScale = calculatePopScale(animationProgress, wordAnim.pop.scale);
358363
}
359364

@@ -382,15 +387,46 @@ function updateWordAnimationLayer(
382387
ctx.wordGroup.add(wordText);
383388
}
384389

385-
// for background calculate interpolated position (render after non-active words)
386-
if (activePos && wordAnim.showBackground) {
387-
const isNewWord = ctx.lastActiveWordIndex !== activeWordIndex;
388-
const bgPos = interpolateBackgroundPosition(
389-
activePos,
390-
ctx.prevWordPosition,
391-
animationProgress,
392-
isNewWord
393-
);
390+
// Calculate background position - use slide animation if enabled, otherwise use old interpolation
391+
if (activePos && wordAnim.enableBackground) {
392+
let bgPos: { x: number; y: number; width: number };
393+
394+
if (wordAnim.enableSlide) {
395+
// Use new slide animation with proper transition tracking
396+
const { position, newTransitionState } = calculateSlidePosition(
397+
activePos,
398+
activeWordIndex,
399+
animationProgress,
400+
ctx.slideTransitionState ?? null,
401+
ctx.prevWordPosition ?? null,
402+
0.3, // 30% slide duration
403+
);
404+
bgPos = position;
405+
ctx.slideTransitionState = newTransitionState;
406+
407+
// Update previous word position for next word change
408+
ctx.prevWordPosition = {
409+
x: activePos.x,
410+
y: activePos.y,
411+
width: activePos.width,
412+
};
413+
} else {
414+
// Use old interpolation method
415+
const isNewWord = ctx.lastActiveWordIndex !== activeWordIndex;
416+
bgPos = interpolateBackgroundPosition(
417+
activePos,
418+
ctx.prevWordPosition,
419+
animationProgress,
420+
isNewWord
421+
);
422+
423+
// Update previous word position for old method
424+
ctx.prevWordPosition = {
425+
x: activePos.x,
426+
y: activePos.y,
427+
width: activePos.width,
428+
};
429+
}
394430

395431
const bgPadX = wordAnim.background.paddingX;
396432
const bgPadY = wordAnim.background.paddingY;
@@ -412,12 +448,6 @@ function updateWordAnimationLayer(
412448
cornerRadius: wordAnim.background.borderRadius,
413449
});
414450
ctx.wordGroup.add(bgRect);
415-
416-
ctx.prevWordPosition = {
417-
x: activePos.x,
418-
y: activePos.y,
419-
width: activePos.width,
420-
};
421451
}
422452

423453
// Render active word last (on top)
@@ -429,13 +459,14 @@ function updateWordAnimationLayer(
429459
let offsetY = 0;
430460

431461
// Font effect: change text color and/or font weight (fallback to main style)
432-
if (wordAnim.showFont) {
462+
if (wordAnim.enableFont) {
433463
fillColor = wordAnim.font.color ?? ctx.style.color;
434464
fontWeight = wordAnim.font.fontWeight ?? ctx.style.fontWeight;
435465
}
436466

437467
// Pop effect: scale animation (only for active word)
438-
if (wordAnim.showPop) {
468+
// Only apply if slide is not enabled (they are mutually exclusive)
469+
if (wordAnim.enablePop && !wordAnim.enableSlide) {
439470
scale = popScale;
440471
// Offset to scale from center of the word
441472
offsetX = (activePos.width * (scale - 1)) / 2;

src/screens/editor/EditorCanvas.tsx

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
calculateActualWidth,
2626
interpolateBackgroundPosition,
2727
calculateScaledBackground,
28+
calculateSlidePosition,
29+
SlideTransitionState,
2830
} from "../../codecs/active-word";
2931

3032
type EditorCanvasProps = {
@@ -122,6 +124,9 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
122124
width: number;
123125
} | null>(null);
124126

127+
// Slide transition state for smooth background animation
128+
const slideTransitionRef = React.useRef<SlideTransitionState | null>(null);
129+
125130
const animationProgress = React.useMemo(() => {
126131
if (!wordChunks || activeWordIndex < 0) return 0;
127132
return calculateAnimationProgress(wordChunks, activeWordIndex, player.ts);
@@ -346,30 +351,60 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
346351
activeWordIndex >= 0
347352
? wordPositions.find((p) => p.index === activeWordIndex)
348353
: null;
354+
355+
// Pop scale is only used when enablePop is enabled AND slide is not enabled
349356
const popScale =
350-
wordAnim.showPop && activePos
357+
wordAnim.enablePop && !wordAnim.enableSlide && activePos
351358
? calculatePopScale(animationProgress, wordAnim.pop.scale)
352359
: 1;
353360

354-
const prevWord = prevActiveWordRef.current;
355-
const isNewWord = prevWord !== null && prevWord.index !== activeWordIndex;
356-
const bgPos =
357-
activePos && wordAnim.showBackground
358-
? interpolateBackgroundPosition(
359-
activePos,
360-
prevWord,
361-
animationProgress,
362-
isNewWord,
363-
)
364-
: { x: 0, y: 0, width: 0 };
365-
366-
if (activePos && wordAnim.showBackground) {
367-
prevActiveWordRef.current = {
368-
index: activeWordIndex,
369-
x: activePos.x,
370-
y: activePos.y,
371-
width: activePos.width,
372-
};
361+
// Calculate background position - use slide animation if enabled, otherwise use old interpolation
362+
let bgPos = { x: 0, y: 0, width: 0 };
363+
364+
if (activePos && wordAnim.enableBackground) {
365+
if (wordAnim.enableSlide) {
366+
// Get previous word position for slide animation
367+
const prevWord = prevActiveWordRef.current;
368+
const prevActivePos = prevWord ? { x: prevWord.x, y: prevWord.y, width: prevWord.width } : null;
369+
370+
// Use new slide animation with proper transition tracking
371+
const { position, newTransitionState } = calculateSlidePosition(
372+
activePos,
373+
activeWordIndex,
374+
animationProgress,
375+
slideTransitionRef.current,
376+
prevActivePos,
377+
0.3, // 30% slide duration
378+
);
379+
bgPos = position;
380+
slideTransitionRef.current = newTransitionState;
381+
382+
// Update previous word reference for next word change
383+
prevActiveWordRef.current = {
384+
index: activeWordIndex,
385+
x: activePos.x,
386+
y: activePos.y,
387+
width: activePos.width,
388+
};
389+
} else {
390+
// Use old interpolation method
391+
const prevWord = prevActiveWordRef.current;
392+
const isNewWord = prevWord !== null && prevWord.index !== activeWordIndex;
393+
bgPos = interpolateBackgroundPosition(
394+
activePos,
395+
prevWord,
396+
animationProgress,
397+
isNewWord,
398+
);
399+
400+
// Update previous word reference for old method
401+
prevActiveWordRef.current = {
402+
index: activeWordIndex,
403+
x: activePos.x,
404+
y: activePos.y,
405+
width: activePos.width,
406+
};
407+
}
373408
}
374409

375410
const bgPadX = wordAnim.background.paddingX;
@@ -427,7 +462,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
427462
})}
428463

429464
{/* Render active word background and text last (on top) */}
430-
{activePos && wordAnim.showBackground && (
465+
{activePos && wordAnim.enableBackground && (
431466
<Rect
432467
x={bgPos.x - bgPadX - scaledBg.offsetX}
433468
y={bgPos.y - bgPadY - scaledBg.offsetY}
@@ -452,13 +487,14 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
452487
let offsetY = 0;
453488

454489
// Font effect: change text color and/or font weight (fallback to main style)
455-
if (wordAnim.showFont) {
490+
if (wordAnim.enableFont) {
456491
fillColor = wordAnim.font.color ?? subtitleStyle.color;
457492
fontWeight = wordAnim.font.fontWeight ?? subtitleStyle.fontWeight;
458493
}
459494

460495
// Pop effect: scale animation (reuse pre-calculated popScale)
461-
if (wordAnim.showPop) {
496+
// Only apply if slide is not enabled (they are mutually exclusive)
497+
if (wordAnim.enablePop && !wordAnim.enableSlide) {
462498
scale = popScale;
463499
// Offset to scale from center of the word
464500
offsetX = (activePos.width * (scale - 1)) / 2;

src/screens/editor/Style.gen.tsx

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)