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:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
@@ -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,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(),
});