feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { ShortVideo } from "./compositions/ShortVideo";
|
||||
import type { ShortVideoProps } from "./lib/types";
|
||||
import { shortVideoPropsSchema } from "./lib/types";
|
||||
|
||||
const DEFAULT_PROPS: ShortVideoProps = {
|
||||
videoUrl: "",
|
||||
durationInFrames: 900, // 30s at 30fps
|
||||
fps: 30,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
subtitles: {
|
||||
captions: [
|
||||
{ text: "This", startMs: 0, endMs: 400 },
|
||||
{ text: "is", startMs: 400, endMs: 600 },
|
||||
{ text: "a", startMs: 600, endMs: 750 },
|
||||
{ text: "demo", startMs: 750, endMs: 1200 },
|
||||
{ text: "of", startMs: 1200, endMs: 1400 },
|
||||
{ text: "animated", startMs: 1400, endMs: 2000 },
|
||||
{ text: "subtitles", startMs: 2000, endMs: 2800 },
|
||||
{ text: "in", startMs: 2800, endMs: 3000 },
|
||||
{ text: "Remotion", startMs: 3000, endMs: 3800 },
|
||||
{ text: "with", startMs: 4000, endMs: 4300 },
|
||||
{ text: "word", startMs: 4300, endMs: 4700 },
|
||||
{ text: "level", startMs: 4700, endMs: 5100 },
|
||||
{ text: "highlighting", startMs: 5100, endMs: 6000 },
|
||||
],
|
||||
position: "bottom",
|
||||
style: {
|
||||
fontFamily: "Arial",
|
||||
fontSize: 52,
|
||||
fontColor: "#FFFFFF",
|
||||
highlightColor: "#FFDD00",
|
||||
borderColor: "#000000",
|
||||
borderWidth: 3,
|
||||
bgColor: "#000000",
|
||||
bgOpacity: 0,
|
||||
animation: "pop",
|
||||
},
|
||||
},
|
||||
hook: {
|
||||
text: "POV: You just discovered OpenShorts",
|
||||
position: "top",
|
||||
size: "M",
|
||||
entranceAnimation: "spring",
|
||||
displayDurationSec: 5,
|
||||
},
|
||||
effects: {
|
||||
segments: [
|
||||
{
|
||||
startSec: 2,
|
||||
endSec: 5,
|
||||
zoom: 1.2,
|
||||
zoomCenterX: 0.5,
|
||||
zoomCenterY: 0.35,
|
||||
brightness: 1.05,
|
||||
contrast: 1.1,
|
||||
saturate: 1.15,
|
||||
},
|
||||
{
|
||||
startSec: 8,
|
||||
endSec: 12,
|
||||
zoom: 1.15,
|
||||
zoomCenterX: 0.5,
|
||||
zoomCenterY: 0.4,
|
||||
brightness: 1,
|
||||
contrast: 1,
|
||||
saturate: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="ShortVideo"
|
||||
schema={shortVideoPropsSchema}
|
||||
component={ShortVideo}
|
||||
durationInFrames={DEFAULT_PROPS.durationInFrames}
|
||||
fps={DEFAULT_PROPS.fps}
|
||||
width={DEFAULT_PROPS.width}
|
||||
height={DEFAULT_PROPS.height}
|
||||
defaultProps={DEFAULT_PROPS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import type { HookConfig } from "../lib/types";
|
||||
import { notoSerifFontFace, NOTO_SERIF_FONT_FAMILY } from "../lib/fonts";
|
||||
|
||||
interface HookOverlayProps {
|
||||
config: HookConfig;
|
||||
}
|
||||
|
||||
const SIZE_SCALE: Record<string, number> = {
|
||||
S: 0.8,
|
||||
M: 1.0,
|
||||
L: 1.3,
|
||||
};
|
||||
|
||||
const POSITION_STYLE: Record<string, React.CSSProperties> = {
|
||||
top: { top: "18%", bottom: "auto" },
|
||||
center: { top: "50%", bottom: "auto", transform: "translateY(-50%)" },
|
||||
bottom: { top: "68%", bottom: "auto" },
|
||||
};
|
||||
|
||||
export const HookOverlay: React.FC<HookOverlayProps> = ({ config }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const displayFrames = Math.round(config.displayDurationSec * fps);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<style>{notoSerifFontFace}</style>
|
||||
<Sequence from={0} durationInFrames={displayFrames} layout="none">
|
||||
<HookBox config={config} displayFrames={displayFrames} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
interface HookBoxProps {
|
||||
config: HookConfig;
|
||||
displayFrames: number;
|
||||
}
|
||||
|
||||
const HookBox: React.FC<HookBoxProps> = ({ config, displayFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const scale = SIZE_SCALE[config.size] ?? 1.0;
|
||||
|
||||
// Entrance animation
|
||||
let animOpacity = 1;
|
||||
let animScale = 1;
|
||||
let animTranslateY = 0;
|
||||
|
||||
switch (config.entranceAnimation) {
|
||||
case "spring": {
|
||||
const prog = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { mass: 0.8, stiffness: 200, damping: 15 },
|
||||
durationInFrames: 20,
|
||||
});
|
||||
animScale = interpolate(prog, [0, 1], [0.7, 1]);
|
||||
animOpacity = interpolate(prog, [0, 1], [0, 1]);
|
||||
break;
|
||||
}
|
||||
case "fade": {
|
||||
animOpacity = interpolate(frame, [0, 15], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "slide-up": {
|
||||
const prog = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { mass: 1, stiffness: 150, damping: 18 },
|
||||
durationInFrames: 20,
|
||||
});
|
||||
animTranslateY = interpolate(prog, [0, 1], [60, 0]);
|
||||
animOpacity = interpolate(prog, [0, 1], [0, 1]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Exit fade (last 15 frames)
|
||||
const fadeOutStart = displayFrames - 15;
|
||||
if (frame > fadeOutStart) {
|
||||
animOpacity *= interpolate(frame, [fadeOutStart, displayFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
}
|
||||
|
||||
const positionStyle = POSITION_STYLE[config.position] ?? POSITION_STYLE.top;
|
||||
|
||||
// Base font size: 5% of 1080 width (matches hooks.py logic)
|
||||
const baseFontSize = 1080 * 0.05;
|
||||
const fontSize = Math.round(baseFontSize * scale);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
...positionStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: animOpacity,
|
||||
transform: `scale(${animScale}) translateY(${animTranslateY}px)`,
|
||||
maxWidth: "90%",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.94)",
|
||||
borderRadius: 20,
|
||||
padding: `${25 * scale}px ${30 * scale}px`,
|
||||
boxShadow: "5px 5px 15px rgba(0, 0, 0, 0.25)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: `'${NOTO_SERIF_FONT_FAMILY}', 'Noto Serif', Georgia, serif`,
|
||||
fontSize,
|
||||
fontWeight: 700,
|
||||
color: "#000000",
|
||||
lineHeight: 1.4,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{config.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { Video } from "@remotion/media";
|
||||
import type { ShortVideoProps } from "../lib/types";
|
||||
import { Subtitles } from "./Subtitles";
|
||||
import { HookOverlay } from "./HookOverlay";
|
||||
import { VideoEffects } from "./VideoEffects";
|
||||
|
||||
/**
|
||||
* Main composition that layers all post-processing on top of the base video.
|
||||
* Uses @remotion/media Video for browser-side rendering compatibility.
|
||||
*/
|
||||
export const ShortVideo: React.FC<Record<string, unknown>> = (rawProps) => {
|
||||
const { videoUrl, subtitles, hook, effects } =
|
||||
rawProps as unknown as ShortVideoProps;
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#000" }}>
|
||||
{/* Layer 1: Base video with optional zoom/color effects */}
|
||||
<VideoEffects config={effects}>
|
||||
<Video
|
||||
src={videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</VideoEffects>
|
||||
|
||||
{/* Layer 2: Animated subtitles */}
|
||||
{subtitles && <Subtitles config={subtitles} />}
|
||||
|
||||
{/* Layer 3: Hook text overlay */}
|
||||
{hook && <HookOverlay config={hook} />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import type { SubtitleConfig } from "../lib/types";
|
||||
import { groupCaptionsIntoBlocks, getActiveWordIndex } from "../lib/captions";
|
||||
import { getFontStack } from "../lib/fonts";
|
||||
|
||||
interface SubtitlesProps {
|
||||
config: SubtitleConfig;
|
||||
}
|
||||
|
||||
const POSITION_MAP: Record<string, React.CSSProperties> = {
|
||||
top: { top: "12%", bottom: "auto" },
|
||||
middle: { top: "45%", bottom: "auto" },
|
||||
bottom: { bottom: "10%", top: "auto" },
|
||||
};
|
||||
|
||||
export const Subtitles: React.FC<SubtitlesProps> = ({ config }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const blocks = groupCaptionsIntoBlocks(config.captions);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{blocks.map((block, i) => {
|
||||
const startFrame = Math.round((block.startMs / 1000) * fps);
|
||||
const durationFrames = Math.max(
|
||||
1,
|
||||
Math.round(((block.endMs - block.startMs) / 1000) * fps)
|
||||
);
|
||||
|
||||
return (
|
||||
<Sequence
|
||||
key={i}
|
||||
from={startFrame}
|
||||
durationInFrames={durationFrames}
|
||||
layout="none"
|
||||
>
|
||||
<SubtitleBlock
|
||||
block={block}
|
||||
config={config}
|
||||
blockStartMs={block.startMs}
|
||||
/>
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubtitleBlockProps {
|
||||
block: ReturnType<typeof groupCaptionsIntoBlocks>[number];
|
||||
config: SubtitleConfig;
|
||||
blockStartMs: number;
|
||||
}
|
||||
|
||||
const SubtitleBlock: React.FC<SubtitleBlockProps> = ({
|
||||
block,
|
||||
config,
|
||||
blockStartMs,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const { style, position } = config;
|
||||
|
||||
// Current time relative to composition start (sequence-relative frame)
|
||||
const currentTimeMs = blockStartMs + (frame / fps) * 1000;
|
||||
const activeIndex = getActiveWordIndex(block.words, currentTimeMs);
|
||||
|
||||
const positionStyle = POSITION_MAP[position] ?? POSITION_MAP.bottom;
|
||||
const fontStack = getFontStack(style.fontFamily);
|
||||
|
||||
// Background box style
|
||||
const hasBg = style.bgOpacity > 0;
|
||||
const bgStyle: React.CSSProperties = hasBg
|
||||
? {
|
||||
backgroundColor: `${style.bgColor}${Math.round(style.bgOpacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")}`,
|
||||
borderRadius: 8,
|
||||
padding: "8px 16px",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
...positionStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "6px 8px",
|
||||
maxWidth: "85%",
|
||||
...bgStyle,
|
||||
}}
|
||||
>
|
||||
{block.words.map((word, i) => (
|
||||
<WordSpan
|
||||
key={i}
|
||||
word={word.text}
|
||||
isActive={i === activeIndex}
|
||||
style={style}
|
||||
fontStack={fontStack}
|
||||
animation={style.animation}
|
||||
frame={frame}
|
||||
fps={fps}
|
||||
wordStartMs={word.startMs}
|
||||
blockStartMs={blockStartMs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface WordSpanProps {
|
||||
word: string;
|
||||
isActive: boolean;
|
||||
style: SubtitleConfig["style"];
|
||||
fontStack: string;
|
||||
animation: SubtitleConfig["style"]["animation"];
|
||||
frame: number;
|
||||
fps: number;
|
||||
wordStartMs: number;
|
||||
blockStartMs: number;
|
||||
}
|
||||
|
||||
const WordSpan: React.FC<WordSpanProps> = ({
|
||||
word,
|
||||
isActive,
|
||||
style,
|
||||
fontStack,
|
||||
animation,
|
||||
frame,
|
||||
fps,
|
||||
wordStartMs,
|
||||
blockStartMs,
|
||||
}) => {
|
||||
const wordStartFrame = Math.round(
|
||||
((wordStartMs - blockStartMs) / 1000) * fps
|
||||
);
|
||||
|
||||
let transform = "";
|
||||
let color = style.fontColor;
|
||||
let extraStyle: React.CSSProperties = {};
|
||||
|
||||
if (isActive) {
|
||||
color = style.highlightColor;
|
||||
|
||||
switch (animation) {
|
||||
case "pop": {
|
||||
const scale = spring({
|
||||
frame: frame - wordStartFrame,
|
||||
fps,
|
||||
config: { mass: 0.5, stiffness: 300, damping: 12 },
|
||||
durationInFrames: 10,
|
||||
});
|
||||
const scaleValue = interpolate(scale, [0, 1], [1, 1.25]);
|
||||
transform = `scale(${scaleValue})`;
|
||||
break;
|
||||
}
|
||||
case "karaoke": {
|
||||
extraStyle = {
|
||||
backgroundColor: style.highlightColor,
|
||||
color: style.bgColor || "#000000",
|
||||
borderRadius: 4,
|
||||
padding: "2px 6px",
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "word-highlight": {
|
||||
extraStyle = {
|
||||
textShadow: `0 0 12px ${style.highlightColor}, 0 0 24px ${style.highlightColor}40`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Text stroke via textShadow (CSS paint-order not reliable in Remotion)
|
||||
const strokeShadow =
|
||||
style.borderWidth > 0
|
||||
? [
|
||||
`${style.borderWidth}px 0 0 ${style.borderColor}`,
|
||||
`-${style.borderWidth}px 0 0 ${style.borderColor}`,
|
||||
`0 ${style.borderWidth}px 0 ${style.borderColor}`,
|
||||
`0 -${style.borderWidth}px 0 ${style.borderColor}`,
|
||||
].join(", ")
|
||||
: "none";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: fontStack,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: 700,
|
||||
color: animation === "karaoke" && isActive ? undefined : color,
|
||||
textShadow:
|
||||
animation !== "karaoke"
|
||||
? [strokeShadow, extraStyle.textShadow].filter(Boolean).join(", ")
|
||||
: strokeShadow,
|
||||
transform,
|
||||
display: "inline-block",
|
||||
transition: "none",
|
||||
...extraStyle,
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
|
||||
import type { EffectsConfig, EffectSegment } from "../lib/types";
|
||||
|
||||
interface VideoEffectsProps {
|
||||
config: EffectsConfig | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children (typically <OffthreadVideo>) with dynamic CSS transforms and filters.
|
||||
* Interpolates smoothly between effect segments.
|
||||
*/
|
||||
export const VideoEffects: React.FC<VideoEffectsProps> = ({
|
||||
config,
|
||||
children,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
if (!config || config.segments.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const currentTimeSec = frame / fps;
|
||||
const { zoom, centerX, centerY, brightness, contrast, saturate } =
|
||||
getInterpolatedValues(config.segments, currentTimeSec, frame, fps);
|
||||
|
||||
const filterParts: string[] = [];
|
||||
if (brightness !== 1) filterParts.push(`brightness(${brightness})`);
|
||||
if (contrast !== 1) filterParts.push(`contrast(${contrast})`);
|
||||
if (saturate !== 1) filterParts.push(`saturate(${saturate})`);
|
||||
const filterStr = filterParts.length > 0 ? filterParts.join(" ") : "none";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: `${centerX * 100}% ${centerY * 100}%`,
|
||||
filter: filterStr,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface InterpolatedValues {
|
||||
zoom: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturate: number;
|
||||
}
|
||||
|
||||
function getInterpolatedValues(
|
||||
segments: EffectSegment[],
|
||||
timeSec: number,
|
||||
frame: number,
|
||||
fps: number
|
||||
): InterpolatedValues {
|
||||
// Default values (no effect)
|
||||
const defaults: InterpolatedValues = {
|
||||
zoom: 1,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5,
|
||||
brightness: 1,
|
||||
contrast: 1,
|
||||
saturate: 1,
|
||||
};
|
||||
|
||||
// Find active segment
|
||||
const active = segments.find(
|
||||
(s) => timeSec >= s.startSec && timeSec < s.endSec
|
||||
);
|
||||
|
||||
if (!active) {
|
||||
// Check if we're transitioning between segments (smooth fade)
|
||||
const prev = segments.filter((s) => s.endSec <= timeSec).pop();
|
||||
const next = segments.find((s) => s.startSec > timeSec);
|
||||
|
||||
if (prev && next) {
|
||||
const gap = next.startSec - prev.endSec;
|
||||
if (gap < 1.0) {
|
||||
// Short gap: interpolate between prev and next
|
||||
const progress = (timeSec - prev.endSec) / gap;
|
||||
return lerpSegments(prev, next, progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Transition out from previous segment
|
||||
if (prev) {
|
||||
const fadeOutDuration = 0.3; // seconds
|
||||
const elapsed = timeSec - prev.endSec;
|
||||
if (elapsed < fadeOutDuration) {
|
||||
const progress = elapsed / fadeOutDuration;
|
||||
return lerpToDefaults(prev, progress, defaults);
|
||||
}
|
||||
}
|
||||
|
||||
// Transition into next segment
|
||||
if (next) {
|
||||
const fadeInDuration = 0.3;
|
||||
const remaining = next.startSec - timeSec;
|
||||
if (remaining < fadeInDuration) {
|
||||
const progress = 1 - remaining / fadeInDuration;
|
||||
return lerpFromDefaults(next, progress, defaults);
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Inside active segment: smooth entrance/exit at edges
|
||||
const segDuration = active.endSec - active.startSec;
|
||||
const transitionSec = Math.min(0.3, segDuration * 0.15);
|
||||
|
||||
const startFrame = Math.round(active.startSec * fps);
|
||||
const endFrame = Math.round(active.endSec * fps);
|
||||
const transitionFrames = Math.round(transitionSec * fps);
|
||||
|
||||
// Entrance ease
|
||||
const entranceFactor = interpolate(
|
||||
frame,
|
||||
[startFrame, startFrame + transitionFrames],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Exit ease
|
||||
const exitFactor = interpolate(
|
||||
frame,
|
||||
[endFrame - transitionFrames, endFrame],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const factor = Math.min(entranceFactor, exitFactor);
|
||||
|
||||
return {
|
||||
zoom: lerp(1, active.zoom, factor),
|
||||
centerX: lerp(0.5, active.zoomCenterX, factor),
|
||||
centerY: lerp(0.5, active.zoomCenterY, factor),
|
||||
brightness: lerp(1, active.brightness, factor),
|
||||
contrast: lerp(1, active.contrast, factor),
|
||||
saturate: lerp(1, active.saturate, factor),
|
||||
};
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function lerpSegments(
|
||||
a: EffectSegment,
|
||||
b: EffectSegment,
|
||||
t: number
|
||||
): InterpolatedValues {
|
||||
return {
|
||||
zoom: lerp(a.zoom, b.zoom, t),
|
||||
centerX: lerp(a.zoomCenterX, b.zoomCenterX, t),
|
||||
centerY: lerp(a.zoomCenterY, b.zoomCenterY, t),
|
||||
brightness: lerp(a.brightness, b.brightness, t),
|
||||
contrast: lerp(a.contrast, b.contrast, t),
|
||||
saturate: lerp(a.saturate, b.saturate, t),
|
||||
};
|
||||
}
|
||||
|
||||
function lerpToDefaults(
|
||||
seg: EffectSegment,
|
||||
t: number,
|
||||
defaults: InterpolatedValues
|
||||
): InterpolatedValues {
|
||||
return {
|
||||
zoom: lerp(seg.zoom, defaults.zoom, t),
|
||||
centerX: lerp(seg.zoomCenterX, defaults.centerX, t),
|
||||
centerY: lerp(seg.zoomCenterY, defaults.centerY, t),
|
||||
brightness: lerp(seg.brightness, defaults.brightness, t),
|
||||
contrast: lerp(seg.contrast, defaults.contrast, t),
|
||||
saturate: lerp(seg.saturate, defaults.saturate, t),
|
||||
};
|
||||
}
|
||||
|
||||
function lerpFromDefaults(
|
||||
seg: EffectSegment,
|
||||
t: number,
|
||||
defaults: InterpolatedValues
|
||||
): InterpolatedValues {
|
||||
return {
|
||||
zoom: lerp(defaults.zoom, seg.zoom, t),
|
||||
centerX: lerp(defaults.centerX, seg.zoomCenterX, t),
|
||||
centerY: lerp(defaults.centerY, seg.zoomCenterY, t),
|
||||
brightness: lerp(defaults.brightness, seg.brightness, t),
|
||||
contrast: lerp(defaults.contrast, seg.contrast, t),
|
||||
saturate: lerp(defaults.saturate, seg.saturate, t),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { CaptionWord } from "./types";
|
||||
|
||||
export interface CaptionBlock {
|
||||
words: CaptionWord[];
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups word-level captions into display blocks.
|
||||
* Same logic as OpenShorts' generate_srt: max chars per block, max duration per block.
|
||||
*/
|
||||
export function groupCaptionsIntoBlocks(
|
||||
captions: CaptionWord[],
|
||||
maxChars = 20,
|
||||
maxDurationMs = 2000
|
||||
): CaptionBlock[] {
|
||||
const blocks: CaptionBlock[] = [];
|
||||
let currentWords: CaptionWord[] = [];
|
||||
let blockStartMs = 0;
|
||||
|
||||
for (const word of captions) {
|
||||
if (currentWords.length === 0) {
|
||||
currentWords.push(word);
|
||||
blockStartMs = word.startMs;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTextLen = currentWords.reduce(
|
||||
(sum, w) => sum + w.text.length + 1,
|
||||
0
|
||||
);
|
||||
const duration = word.endMs - blockStartMs;
|
||||
|
||||
if (
|
||||
currentTextLen + word.text.length > maxChars ||
|
||||
duration > maxDurationMs
|
||||
) {
|
||||
// Finalize current block
|
||||
const lastWord = currentWords[currentWords.length - 1];
|
||||
blocks.push({
|
||||
words: [...currentWords],
|
||||
startMs: blockStartMs,
|
||||
endMs: lastWord.endMs,
|
||||
text: currentWords.map((w) => w.text).join(" "),
|
||||
});
|
||||
|
||||
currentWords = [word];
|
||||
blockStartMs = word.startMs;
|
||||
} else {
|
||||
currentWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
// Final block
|
||||
if (currentWords.length > 0) {
|
||||
const lastWord = currentWords[currentWords.length - 1];
|
||||
blocks.push({
|
||||
words: [...currentWords],
|
||||
startMs: blockStartMs,
|
||||
endMs: lastWord.endMs,
|
||||
text: currentWords.map((w) => w.text).join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the active word at a given time in milliseconds.
|
||||
*/
|
||||
export function getActiveWordIndex(
|
||||
words: CaptionWord[],
|
||||
timeMs: number
|
||||
): number {
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (timeMs >= words[i].startMs && timeMs < words[i].endMs) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
/**
|
||||
* CSS @font-face declaration for NotoSerif-Bold (bundled locally).
|
||||
* Use in components via: <style>{notoSerifFontFace}</style>
|
||||
*/
|
||||
export const NOTO_SERIF_FONT_FAMILY = "NotoSerif-Bold";
|
||||
|
||||
export const notoSerifFontFace = `
|
||||
@font-face {
|
||||
font-family: '${NOTO_SERIF_FONT_FAMILY}';
|
||||
src: url('${staticFile("fonts/NotoSerif-Bold.ttf")}') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Map of subtitle font families to their CSS-safe names.
|
||||
* These match the options available in SubtitleModal.jsx.
|
||||
*/
|
||||
export const SUBTITLE_FONTS: Record<string, string> = {
|
||||
Verdana: "Verdana, Geneva, sans-serif",
|
||||
Arial: "Arial, Helvetica, sans-serif",
|
||||
Impact: "Impact, Haettenschweiler, sans-serif",
|
||||
Helvetica: "Helvetica, Arial, sans-serif",
|
||||
Georgia: "Georgia, 'Times New Roman', serif",
|
||||
"Courier New": "'Courier New', Courier, monospace",
|
||||
};
|
||||
|
||||
export function getFontStack(fontFamily: string): string {
|
||||
return SUBTITLE_FONTS[fontFamily] ?? fontFamily;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Word-level caption ---
|
||||
export interface CaptionWord {
|
||||
text: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
// --- Subtitle config ---
|
||||
export type SubtitleAnimation = "none" | "word-highlight" | "pop" | "karaoke";
|
||||
export type SubtitlePosition = "top" | "middle" | "bottom";
|
||||
|
||||
export interface SubtitleStyle {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
highlightColor: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
bgColor: string;
|
||||
bgOpacity: number;
|
||||
animation: SubtitleAnimation;
|
||||
}
|
||||
|
||||
export interface SubtitleConfig {
|
||||
captions: CaptionWord[];
|
||||
position: SubtitlePosition;
|
||||
style: SubtitleStyle;
|
||||
}
|
||||
|
||||
// --- Hook config ---
|
||||
export type HookPosition = "top" | "center" | "bottom";
|
||||
export type HookSize = "S" | "M" | "L";
|
||||
export type HookEntrance = "spring" | "fade" | "slide-up" | "none";
|
||||
|
||||
export interface HookConfig {
|
||||
text: string;
|
||||
position: HookPosition;
|
||||
size: HookSize;
|
||||
entranceAnimation: HookEntrance;
|
||||
displayDurationSec: number;
|
||||
}
|
||||
|
||||
// --- Effects config ---
|
||||
export interface EffectSegment {
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
zoom: number;
|
||||
zoomCenterX: number;
|
||||
zoomCenterY: number;
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturate: number;
|
||||
}
|
||||
|
||||
export interface EffectsConfig {
|
||||
segments: EffectSegment[];
|
||||
}
|
||||
|
||||
// --- Main composition props ---
|
||||
export interface ShortVideoProps {
|
||||
videoUrl: string;
|
||||
durationInFrames: number;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
subtitles: SubtitleConfig | null;
|
||||
hook: HookConfig | null;
|
||||
effects: EffectsConfig | null;
|
||||
}
|
||||
|
||||
// --- Zod schemas for validation (used by render service) ---
|
||||
export const captionWordSchema = z.object({
|
||||
text: z.string(),
|
||||
startMs: z.number(),
|
||||
endMs: z.number(),
|
||||
});
|
||||
|
||||
export const subtitleStyleSchema = z.object({
|
||||
fontFamily: z.string(),
|
||||
fontSize: z.number(),
|
||||
fontColor: z.string(),
|
||||
highlightColor: z.string(),
|
||||
borderColor: z.string(),
|
||||
borderWidth: z.number(),
|
||||
bgColor: z.string(),
|
||||
bgOpacity: z.number().min(0).max(1),
|
||||
animation: z.enum(["none", "word-highlight", "pop", "karaoke"]),
|
||||
});
|
||||
|
||||
export const subtitleConfigSchema = z.object({
|
||||
captions: z.array(captionWordSchema),
|
||||
position: z.enum(["top", "middle", "bottom"]),
|
||||
style: subtitleStyleSchema,
|
||||
});
|
||||
|
||||
export const hookConfigSchema = z.object({
|
||||
text: z.string(),
|
||||
position: z.enum(["top", "center", "bottom"]),
|
||||
size: z.enum(["S", "M", "L"]),
|
||||
entranceAnimation: z.enum(["spring", "fade", "slide-up", "none"]),
|
||||
displayDurationSec: z.number().positive(),
|
||||
});
|
||||
|
||||
export const effectSegmentSchema = z.object({
|
||||
startSec: z.number().min(0),
|
||||
endSec: z.number().positive(),
|
||||
zoom: z.number().min(0.5).max(3),
|
||||
zoomCenterX: z.number().min(0).max(1),
|
||||
zoomCenterY: z.number().min(0).max(1),
|
||||
brightness: z.number().min(0).max(3),
|
||||
contrast: z.number().min(0).max(3),
|
||||
saturate: z.number().min(0).max(3),
|
||||
});
|
||||
|
||||
export const effectsConfigSchema = z.object({
|
||||
segments: z.array(effectSegmentSchema),
|
||||
});
|
||||
|
||||
export const shortVideoPropsSchema = z.object({
|
||||
videoUrl: z.string(),
|
||||
durationInFrames: z.number().int().positive(),
|
||||
fps: z.number().positive(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
subtitles: subtitleConfigSchema.nullable(),
|
||||
hook: hookConfigSchema.nullable(),
|
||||
effects: effectsConfigSchema.nullable(),
|
||||
});
|
||||
Reference in New Issue
Block a user