diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 380496bab5..d9b09f2b48 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -17,6 +17,14 @@ WORKDIR /app
ARG FREESOUND_CLIENT_ID
ARG FREESOUND_API_KEY
+# NEXT_PUBLIC_* are inlined at build time — pass VPS URLs as build args.
+ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
+ARG NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
+ARG NEXT_PUBLIC_AI_BACKEND_URL=http://localhost:8420
+ARG NEXT_PUBLIC_OLLAMA_URL=http://localhost:11434
+ARG NEXT_PUBLIC_WHISPER_SERVICE_URL=http://localhost:8421
+ARG NEXT_PUBLIC_TTS_SERVICE_URL=http://localhost:8422
+ARG NEXT_PUBLIC_IMAGE_SERVICE_URL=http://localhost:8423
COPY package.json package.json
COPY bun.lock bun.lock
@@ -43,9 +51,13 @@ ENV DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
ENV BETTER_AUTH_SECRET="build-time-secret"
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
-ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
-ENV NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
-ENV NEXT_PUBLIC_AI_BACKEND_URL="http://localhost:8420"
+ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
+ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
+ENV NEXT_PUBLIC_AI_BACKEND_URL=$NEXT_PUBLIC_AI_BACKEND_URL
+ENV NEXT_PUBLIC_OLLAMA_URL=$NEXT_PUBLIC_OLLAMA_URL
+ENV NEXT_PUBLIC_WHISPER_SERVICE_URL=$NEXT_PUBLIC_WHISPER_SERVICE_URL
+ENV NEXT_PUBLIC_TTS_SERVICE_URL=$NEXT_PUBLIC_TTS_SERVICE_URL
+ENV NEXT_PUBLIC_IMAGE_SERVICE_URL=$NEXT_PUBLIC_IMAGE_SERVICE_URL
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 981cb201c2..a0c8c3bf28 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -48,18 +48,20 @@ export default function RootLayout({
>
-
+ {process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID && (
+
+ )}
{children}
diff --git a/apps/web/src/components/editor/ai/ai-panel-wrapper.tsx b/apps/web/src/components/editor/ai/ai-panel-wrapper.tsx
index f4ff83f7c8..be91396548 100644
--- a/apps/web/src/components/editor/ai/ai-panel-wrapper.tsx
+++ b/apps/web/src/components/editor/ai/ai-panel-wrapper.tsx
@@ -12,6 +12,7 @@ import { cn } from "@/utils/ui";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { SparklesIcon, Cancel01Icon } from "@hugeicons/core-free-icons";
+import { generateUUID } from "@/utils/id";
export function AIPanelWrapper() {
const {
@@ -39,7 +40,7 @@ export function AIPanelWrapper() {
async (message: string) => {
if (!isConnected) {
const errorMessage: AIMessage = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
role: "assistant",
content:
"Cannot process commands — the AI backend is not connected. Open the setup guide from the AI status indicator in the header to get started.",
@@ -50,7 +51,7 @@ export function AIPanelWrapper() {
}
const userMessage: AIMessage = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
role: "user",
content: message,
timestamp: new Date(),
@@ -61,12 +62,12 @@ export function AIPanelWrapper() {
const result = await executeCommand(message, null);
const assistantMessage: AIMessage = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
role: "assistant",
content: result.explanation,
timestamp: new Date(),
actions: result.actions.map((action) => ({
- id: crypto.randomUUID(),
+ id: generateUUID(),
type: action.type,
label: action.type.replace(/_/g, " ").toLowerCase(),
description: action.description,
@@ -84,7 +85,7 @@ export function AIPanelWrapper() {
errorDetail.includes("Failed to fetch");
const errorMessage: AIMessage = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
role: "assistant",
content: isConnectionIssue
? `Cannot reach the AI backend: ${errorDetail}\n\nMake sure the AI backend is running at the configured URL. Open the setup guide for instructions.`
diff --git a/apps/web/src/components/editor/panels/assets/views/ai-studio.tsx b/apps/web/src/components/editor/panels/assets/views/ai-studio.tsx
index a24262e891..e64ff08cae 100644
--- a/apps/web/src/components/editor/panels/assets/views/ai-studio.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/ai-studio.tsx
@@ -22,6 +22,7 @@ import { useAIStatus } from "@/hooks/use-ai-status";
import { useAIStore } from "@/stores/ai-store";
import { useTranscriptStore } from "@/stores/transcript-store";
import { toast } from "sonner";
+import { generateUUID } from "@/utils/id";
import { TemplatePanel } from "@/components/editor/ai/template-panel";
import { BRollSuggestionsPanel } from "@/components/editor/ai/broll-suggestions-panel";
import { YouTubeReelsPanel } from "@/components/editor/youtube/youtube-reels-panel";
@@ -364,14 +365,14 @@ export function AIStudioView() {
}
addMessage({
- id: crypto.randomUUID(),
+ id: generateUUID(),
role: "user",
content: trimmed,
});
setInputValue("");
setIsThinking(true);
- const assistantId = crypto.randomUUID();
+ const assistantId = generateUUID();
let messageAdded = false;
try {
diff --git a/apps/web/src/components/editor/panels/timeline/audio-effects-panel.tsx b/apps/web/src/components/editor/panels/timeline/audio-effects-panel.tsx
index 8dd84fa6e3..71181c1a67 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-effects-panel.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-effects-panel.tsx
@@ -9,14 +9,11 @@ import { Slider } from "@/components/ui/slider";
import { HugeiconsIcon } from "@hugeicons/react";
import { Add01Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
import { useEditor } from "@/hooks/use-editor";
-import { getAllAudioEffectDefinitions, type AudioEffectType } from "@/lib/audio/audio-effects";
-
-interface TrackAudioEffect {
- id: string;
- type: AudioEffectType;
- params: Record;
- enabled: boolean;
-}
+import {
+ getAllAudioEffectDefinitions,
+ type AudioEffectType,
+ type TrackAudioEffect,
+} from "@/lib/audio/audio-effects";
interface AudioEffectsChainProps {
trackId: string;
@@ -29,7 +26,10 @@ export function AudioEffectsChainPanel({ trackId, trackType, className }: AudioE
const track = editor.timeline.getTrackById({ trackId });
if (!track || (trackType !== "audio" && trackType !== "video")) return null;
- const effects: TrackAudioEffect[] = (track as any).audioEffects ?? [];
+ const effects: TrackAudioEffect[] =
+ (track.type === "audio" || track.type === "video"
+ ? track.audioEffects
+ : undefined) ?? [];
const handleAdd = useCallback(
(type: AudioEffectType) => {
@@ -45,7 +45,7 @@ export function AudioEffectsChainPanel({ trackId, trackType, className }: AudioE
];
editor.timeline.updateTrack({
trackId,
- updates: { audioEffects: updatedEffects } as any,
+ updates: { audioEffects: updatedEffects },
});
},
[effects, trackId, editor],
@@ -56,7 +56,7 @@ export function AudioEffectsChainPanel({ trackId, trackType, className }: AudioE
const updatedEffects = effects.filter((e) => e.id !== effectId);
editor.timeline.updateTrack({
trackId,
- updates: { audioEffects: updatedEffects } as any,
+ updates: { audioEffects: updatedEffects },
});
},
[effects, trackId, editor],
@@ -69,7 +69,7 @@ export function AudioEffectsChainPanel({ trackId, trackType, className }: AudioE
);
editor.timeline.updateTrack({
trackId,
- updates: { audioEffects: updatedEffects } as any,
+ updates: { audioEffects: updatedEffects },
});
},
[effects, trackId, editor],
@@ -82,7 +82,7 @@ export function AudioEffectsChainPanel({ trackId, trackType, className }: AudioE
);
editor.timeline.updateTrack({
trackId,
- updates: { audioEffects: updatedEffects } as any,
+ updates: { audioEffects: updatedEffects },
});
},
[effects, trackId, editor],
diff --git a/apps/web/src/components/editor/panels/timeline/audio-mixer-panel.tsx b/apps/web/src/components/editor/panels/timeline/audio-mixer-panel.tsx
index e4fac4718a..0df08e4d3e 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-mixer-panel.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-mixer-panel.tsx
@@ -4,6 +4,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/utils/ui";
import type { TimelineTrack, VideoTrack, AudioTrack } from "@/types/timeline";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { AudioEffectsChainPanel } from "./audio-effects-panel";
type AudioTrackish = VideoTrack | AudioTrack;
@@ -113,6 +119,7 @@ function TrackMixerStrip({
const pan = "pan" in track ? track.pan ?? 0 : 0;
const isMuted = track.muted;
const isSolo = track.solo ?? false;
+ const effectCount = track.audioEffects?.length ?? 0;
const handleVolumeChange = useCallback(
(e: React.ChangeEvent) => {
@@ -229,6 +236,25 @@ function TrackMixerStrip({
)}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 5b24dd28d7..3804d37aea 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -1,6 +1,10 @@
import type { EditorCore } from "@/core";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
+import {
+ getAudioEffectDefinition,
+ type TrackAudioEffect,
+} from "@/lib/audio/audio-effects";
import {
ALL_FORMATS,
AudioBufferSink,
@@ -154,6 +158,9 @@ export class AudioManager {
(track && ("volume" in track ? track.volume : undefined)) ?? 1;
const trackPan =
(track && ("pan" in track ? track.pan : undefined)) ?? 0;
+ const audioEffects =
+ (track && "audioEffects" in track ? track.audioEffects : undefined) ??
+ [];
const gain = ctx.createGain();
gain.gain.value = trackVolume;
@@ -165,7 +172,12 @@ export class AudioManager {
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
- gain.connect(panner);
+ this.connectAudioEffectsChain({
+ ctx,
+ input: gain,
+ output: panner,
+ effects: audioEffects,
+ });
panner.connect(analyser);
analyser.connect(this.masterGain);
@@ -174,6 +186,39 @@ export class AudioManager {
return nodes;
}
+ // wires enabled track audio effects (EQ, compressor, reverb, etc.) in
+ // series between the track gain and panner nodes
+ private connectAudioEffectsChain({
+ ctx,
+ input,
+ output,
+ effects,
+ }: {
+ ctx: AudioContext;
+ input: AudioNode;
+ output: AudioNode;
+ effects: TrackAudioEffect[];
+ }): void {
+ const enabledEffects = effects.filter((effect) => effect.enabled);
+ if (enabledEffects.length === 0) {
+ input.connect(output);
+ return;
+ }
+
+ let previousOutput: AudioNode = input;
+ for (const effect of enabledEffects) {
+ const definition = getAudioEffectDefinition(effect.type);
+ if (!definition) continue;
+
+ const nodes = definition.createNodes(ctx, effect.params);
+ if (nodes.length === 0) continue;
+
+ previousOutput.connect(nodes[0]);
+ previousOutput = nodes[nodes.length - 1];
+ }
+ previousOutput.connect(output);
+ }
+
private rebuildTrackNodes(): void {
for (const [, nodes] of this.trackNodes) {
try {
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index e86c883659..5ef813dafa 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -27,6 +27,12 @@ export class PlaybackManager {
this.seek({ time: 0 });
}
+ // Normal play is always 1x. Shuttle sets its own direction/speed via
+ // shuttleForward/shuttleReverse; leaving speed at 0 made time never
+ // advance and, at t=0, every rAF hit newTime <= 0 and stormed
+ // playback-seek events (audio restart / play-pause bootloop).
+ this.shuttleSpeed = 0;
+ this.shuttleDirection = null;
this.isPlaying = true;
this.startTimer();
this.notify();
@@ -34,6 +40,8 @@ export class PlaybackManager {
pause(): void {
this.isPlaying = false;
+ this.shuttleSpeed = 0;
+ this.shuttleDirection = null;
this.stopTimer();
this.notify();
}
@@ -192,7 +200,14 @@ export class PlaybackManager {
const delta = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
- const speed = this.shuttleDirection === "reverse" ? -this.shuttleSpeed : this.shuttleSpeed;
+ // Shuttle inactive → 1x (normal play). Active shuttle uses its speed.
+ let speed = 1;
+ if (this.shuttleDirection === "forward") {
+ speed = this.shuttleSpeed;
+ } else if (this.shuttleDirection === "reverse") {
+ speed = -this.shuttleSpeed;
+ }
+
const newTime = this.currentTime + delta * speed;
const duration = this.editor.timeline.getTotalDuration();
@@ -206,18 +221,26 @@ export class PlaybackManager {
detail: { time: duration },
}),
);
- } else if (newTime <= 0) {
+ return;
+ }
+
+ if (newTime <= 0) {
+ const wasAtStart = this.currentTime <= 0;
this.currentTime = 0;
this.notify();
- window.dispatchEvent(
- new CustomEvent("playback-seek", {
- detail: { time: 0 },
- }),
- );
+ // Avoid seek storms when stuck at 0 (e.g. speed was 0 before the fix)
+ if (!wasAtStart) {
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", {
+ detail: { time: 0 },
+ }),
+ );
+ }
if (this.shuttleDirection === "reverse" && this.shuttleSpeed > 0) {
this.shuttleStop();
+ return;
}
} else {
this.currentTime = newTime;
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 5e6d237f5c..d8956504a1 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,5 +1,6 @@
import type { EditorCore } from "@/core";
import type { EffectParamValues } from "@/types/effects";
+import type { TrackAudioEffect } from "@/lib/audio/audio-effects";
import type {
TrackType,
TimelineTrack,
@@ -619,7 +620,16 @@ export class TimelineManager {
updates,
}: {
trackId: string;
- updates: Partial<{ muted: boolean; hidden: boolean; volume: number; pan: number; solo: boolean; color: string; locked: boolean }>;
+ updates: Partial<{
+ muted: boolean;
+ hidden: boolean;
+ volume: number;
+ pan: number;
+ solo: boolean;
+ color: string;
+ locked: boolean;
+ audioEffects: TrackAudioEffect[];
+ }>;
}): void {
const tracks = this.getTracks();
const updatedTracks = tracks.map((track) =>
diff --git a/apps/web/src/hooks/use-service-health.ts b/apps/web/src/hooks/use-service-health.ts
index 3d18ac6eb1..9056eb92f8 100644
--- a/apps/web/src/hooks/use-service-health.ts
+++ b/apps/web/src/hooks/use-service-health.ts
@@ -29,7 +29,7 @@ export interface AllServicesHealth {
export const SERVICE_URLS = {
backend: process.env.NEXT_PUBLIC_AI_BACKEND_URL || "http://localhost:8420",
- ollama: "http://localhost:11434",
+ ollama: process.env.NEXT_PUBLIC_OLLAMA_URL || "http://localhost:11434",
whisper: process.env.NEXT_PUBLIC_WHISPER_SERVICE_URL || "http://localhost:8421",
tts: process.env.NEXT_PUBLIC_TTS_SERVICE_URL || "http://localhost:8422",
image: process.env.NEXT_PUBLIC_IMAGE_SERVICE_URL || "http://localhost:8423",
diff --git a/apps/web/src/lib/audio/audio-effects.ts b/apps/web/src/lib/audio/audio-effects.ts
index 1cce8f931a..bbeb69b88c 100644
--- a/apps/web/src/lib/audio/audio-effects.ts
+++ b/apps/web/src/lib/audio/audio-effects.ts
@@ -25,6 +25,14 @@ export interface AudioEffectDefinition {
createNodes: (ctx: AudioContext, params: Record) => AudioNode[];
}
+/** A configured audio effect instance stored on a track. */
+export interface TrackAudioEffect {
+ id: string;
+ type: AudioEffectType;
+ params: Record;
+ enabled: boolean;
+}
+
export const AUDIO_EFFECT_DEFINITIONS: Record = {
eq: {
type: "eq",
@@ -40,7 +48,7 @@ export const AUDIO_EFFECT_DEFINITIONS: Record {
const low = ctx.createBiquadFilter();
low.type = "lowshelf";
- low.frequency.value = params.lowGain !== undefined ? (params.lowFreq as number) : 320;
+ low.frequency.value = params.lowFreq ?? 320;
low.gain.value = params.lowGain ?? 0;
const mid = ctx.createBiquadFilter();
@@ -113,6 +121,11 @@ export const AUDIO_EFFECT_DEFINITIONS: Record {
+ // unity fan-out node so both the wet (convolver) and dry paths
+ // receive the same input signal — a single node can't be
+ // connected to twice as a source, so callers must connect to
+ // this node's input and read output from the merger
+ const input = ctx.createGain();
const convolver = ctx.createConvolver();
const wet = ctx.createGain();
const dry = ctx.createGain();
@@ -136,13 +149,15 @@ export const AUDIO_EFFECT_DEFINITIONS: Record;
- mediaIdA?: string;
- mediaIdB?: string;
}
export class TransitionNode extends BaseNode {
@@ -54,12 +52,12 @@ export class TransitionNode extends BaseNode {
const progress = (time - transitionStart) / transitionDuration;
- const canvasA = this.renderSourceFrame({
+ const canvasA = await this.renderSourceFrame({
renderer,
sourceParams: this.params.sourceA,
time,
});
- const canvasB = this.renderSourceFrame({
+ const canvasB = await this.renderSourceFrame({
renderer,
sourceParams: this.params.sourceB,
time,
@@ -80,11 +78,50 @@ export class TransitionNode extends BaseNode {
renderer.context.save();
renderer.context.globalCompositeOperation = "source-over";
+ renderer.context.globalAlpha = 1;
renderer.context.drawImage(result as CanvasImageSource, 0, 0);
renderer.context.restore();
}
- private renderSourceFrame({
+ private buildContentNode({
+ sourceParams,
+ }: {
+ sourceParams: TransitionSourceParams;
+ }): BaseNode | null {
+ if (!sourceParams.mediaId || !sourceParams.mediaType) return null;
+
+ const media = this.params.mediaMap.get(sourceParams.mediaId);
+ if (!media?.url) return null;
+
+ const shared = {
+ duration: sourceParams.duration,
+ timeOffset: sourceParams.timeOffset,
+ trimStart: sourceParams.trimStart,
+ trimEnd: sourceParams.trimEnd,
+ playbackRate: sourceParams.playbackRate,
+ transform: sourceParams.transform,
+ animations: sourceParams.animations,
+ opacity: sourceParams.opacity,
+ blendMode: sourceParams.blendMode,
+ effects: sourceParams.effects,
+ };
+
+ if (sourceParams.mediaType === "video") {
+ if (!media.file) return null;
+ return new VideoNode({
+ ...shared,
+ url: media.url,
+ file: media.file,
+ mediaId: sourceParams.mediaId,
+ });
+ }
+
+ return new ImageNode({ ...shared, url: media.url });
+ }
+
+ // renders the source clip's frame (respecting transform/trim/effects) into
+ // its own offscreen canvas so the transition shader has real pixels to blend
+ private async renderSourceFrame({
renderer,
sourceParams,
time,
@@ -92,7 +129,7 @@ export class TransitionNode extends BaseNode {
renderer: CanvasRenderer;
sourceParams: TransitionSourceParams;
time: number;
- }): HTMLCanvasElement | OffscreenCanvas | null {
+ }): Promise {
const offscreen = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
@@ -103,24 +140,16 @@ export class TransitionNode extends BaseNode {
| null;
if (!ctx) return null;
- const animLocalTime = getElementLocalTime({
- timelineTime: time,
- elementStartTime: sourceParams.timeOffset,
- elementDuration: sourceParams.duration,
- });
-
- const transform = resolveTransformAtTime({
- baseTransform: sourceParams.transform,
- animations: sourceParams.animations,
- localTime: animLocalTime,
- });
- const opacity = resolveOpacityAtTime({
- baseOpacity: sourceParams.opacity,
- animations: sourceParams.animations,
- localTime: animLocalTime,
- });
+ const contentNode = this.buildContentNode({ sourceParams });
+ if (!contentNode) return offscreen;
- (ctx as CanvasRenderingContext2D).globalAlpha = opacity;
+ const originalContext = renderer.context;
+ renderer.context = ctx;
+ try {
+ await contentNode.render({ renderer, time });
+ } finally {
+ renderer.context = originalContext;
+ }
return offscreen;
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 44fb9254c6..18134fe577 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -1,4 +1,9 @@
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type {
+ ImageElement,
+ TimelineTrack,
+ VideoElement,
+ VisualElement,
+} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
@@ -275,10 +280,7 @@ function buildTransitionNodes({
const asset = mediaMap.get(current.mediaId);
if (!asset) continue;
- const nextAsset =
- next.type === "video" || next.type === "image"
- ? mediaMap.get(next.mediaId)
- : null;
+ const nextIsMedia = next.type === "video" || next.type === "image";
transitionNodes.push(
new TransitionNode({
@@ -297,6 +299,8 @@ function buildTransitionNodes({
opacity: current.opacity,
blendMode: current.blendMode,
effects: current.effects,
+ mediaId: current.mediaId,
+ mediaType: current.type,
},
sourceB: {
duration: next.duration,
@@ -310,16 +314,17 @@ function buildTransitionNodes({
opacity: (next as VisualElement).opacity,
blendMode: (next as VisualElement).blendMode,
effects: (next as VisualElement).effects,
+ mediaId: nextIsMedia
+ ? (next as VideoElement | ImageElement).mediaId
+ : undefined,
+ mediaType: nextIsMedia
+ ? (next.type as "video" | "image")
+ : undefined,
},
mediaMap: mediaMap as unknown as Map<
string,
{ url: string; file?: File }
>,
- mediaIdA: current.mediaId,
- mediaIdB:
- next.type === "video" || next.type === "image"
- ? next.mediaId
- : undefined,
}),
);
}
diff --git a/apps/web/src/services/version/ai-commit-message.ts b/apps/web/src/services/version/ai-commit-message.ts
index ecb2d164c0..17c6c3314e 100644
--- a/apps/web/src/services/version/ai-commit-message.ts
+++ b/apps/web/src/services/version/ai-commit-message.ts
@@ -7,27 +7,19 @@ const AI_BACKEND_URL =
/**
* Generate an AI-powered commit message from a diff.
- * Tries the local AI backend first, falls back to Ollama.
+ * All LLM traffic goes through the AI backend (which proxies Ollama);
+ * the browser never talks to Ollama directly.
*/
export async function generateAICommitMessage(
diff: TimelineDiff,
): Promise {
const prompt = buildPrompt(diff);
- // Try AI backend
try {
const message = await tryAIBackend(prompt);
if (message) return message;
} catch {
- // Fall through to Ollama
- }
-
- // Try Ollama
- try {
- const message = await tryOllama(prompt);
- if (message) return message;
- } catch {
- // Both failed
+ // Backend unavailable — no commit message
}
return null;
@@ -80,48 +72,31 @@ async function tryAIBackend(prompt: string): Promise {
const response = await fetch(`${AI_BACKEND_URL}/api/llm/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- messages: [{ role: "user", content: prompt }],
- stream: false,
- }),
+ body: JSON.stringify({ message: prompt }),
signal: controller.signal,
});
if (!response.ok) return null;
- const data = await response.json();
- const text = data?.response || data?.message?.content || data?.choices?.[0]?.message?.content;
+ // Response is newline-delimited JSON: {"token": "..."} then {"done": true}
+ const raw = await response.text();
+ let text = "";
+ for (const line of raw.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ const chunk = JSON.parse(line);
+ if (typeof chunk.token === "string") text += chunk.token;
+ if (chunk.error) return null;
+ } catch {
+ // Skip malformed lines
+ }
+ }
return text ? cleanCommitMessage(text) : null;
} finally {
clearTimeout(timeout);
}
}
-async function tryOllama(prompt: string): Promise {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 15000);
-
- try {
- const response = await fetch("http://localhost:11434/api/generate", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- model: "llama3.2:1b",
- prompt,
- stream: false,
- }),
- signal: controller.signal,
- });
-
- if (!response.ok) return null;
-
- const data = await response.json();
- return data?.response ? cleanCommitMessage(data.response) : null;
- } finally {
- clearTimeout(timeout);
- }
-}
-
function cleanCommitMessage(raw: string): string {
return raw
.replace(/^["']|["']$/g, "") // Remove surrounding quotes
diff --git a/apps/web/src/stores/ai-store.ts b/apps/web/src/stores/ai-store.ts
index 62deaf8430..91d97de8d4 100644
--- a/apps/web/src/stores/ai-store.ts
+++ b/apps/web/src/stores/ai-store.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { AIBackendStatus, AIErrorType, AISuggestion } from "@/types/ai";
+import { generateUUID } from "@/utils/id";
export interface SavedIdea {
id: string;
@@ -122,7 +123,7 @@ export const useAIStore = create()((set) => ({
savedIdeas: [
...state.savedIdeas,
{
- id: crypto.randomUUID(),
+ id: generateUUID(),
content,
savedAt: Date.now(),
},
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 78904ad3c7..cb3b5e650a 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,6 +1,7 @@
import type { ElementAnimations } from "./animation";
import type { Effect, EffectParamValues } from "./effects";
import type { BlendMode, Transform, CropRect, MaskShape } from "./rendering";
+import type { TrackAudioEffect } from "@/lib/audio/audio-effects";
export interface TransitionData {
type: string;
@@ -62,6 +63,7 @@ export interface VideoTrack extends BaseTrack {
hidden: boolean;
volume?: number;
solo?: boolean;
+ audioEffects?: TrackAudioEffect[];
}
export interface TextTrack extends BaseTrack {
@@ -77,6 +79,7 @@ export interface AudioTrack extends BaseTrack {
volume?: number;
pan?: number;
solo?: boolean;
+ audioEffects?: TrackAudioEffect[];
}
export interface StickerTrack extends BaseTrack {
diff --git a/apps/web/src/utils/id.ts b/apps/web/src/utils/id.ts
index e452af434e..229bf6ecac 100644
--- a/apps/web/src/utils/id.ts
+++ b/apps/web/src/utils/id.ts
@@ -1,3 +1,19 @@
+function fillRandomBytes(bytes: Uint8Array): void {
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.getRandomValues === "function"
+ ) {
+ crypto.getRandomValues(bytes);
+ return;
+ }
+
+ // Last-resort fallback for non-secure / legacy contexts.
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = Math.floor(Math.random() * 256);
+ }
+}
+
+/** UUID v4 — works in insecure HTTP contexts where crypto.randomUUID is missing. */
export function generateUUID(): string {
if (
typeof crypto !== "undefined" &&
@@ -7,7 +23,7 @@ export function generateUUID(): string {
}
const bytes = new Uint8Array(16);
- crypto.getRandomValues(bytes);
+ fillRandomBytes(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
diff --git a/docker-compose.yml b/docker-compose.yml
index 5e548bce4f..f8df6ca356 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -135,11 +135,11 @@ services:
volumes:
- tts_models:/root/.cache
environment:
- - TTS_AUTOLOAD=true
+ - TTS_AUTOLOAD=false
deploy:
resources:
limits:
- memory: 6g
+ memory: 3g
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8422/health || exit 1"]
interval: 30s
@@ -261,6 +261,13 @@ services:
args:
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:3100}
+ - NEXT_PUBLIC_MARBLE_API_URL=${NEXT_PUBLIC_MARBLE_API_URL:-https://api.marblecms.com}
+ - NEXT_PUBLIC_AI_BACKEND_URL=${NEXT_PUBLIC_AI_BACKEND_URL:-http://localhost:8420}
+ - NEXT_PUBLIC_OLLAMA_URL=${NEXT_PUBLIC_OLLAMA_URL:-http://localhost:11434}
+ - NEXT_PUBLIC_WHISPER_SERVICE_URL=${NEXT_PUBLIC_WHISPER_SERVICE_URL:-http://localhost:8421}
+ - NEXT_PUBLIC_TTS_SERVICE_URL=${NEXT_PUBLIC_TTS_SERVICE_URL:-http://localhost:8422}
+ - NEXT_PUBLIC_IMAGE_SERVICE_URL=${NEXT_PUBLIC_IMAGE_SERVICE_URL:-http://localhost:8423}
restart: unless-stopped
ports:
- "3100:3000"
@@ -270,9 +277,10 @@ services:
- BETTER_AUTH_SECRET=your-production-secret-key-here
- UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80
- UPSTASH_REDIS_REST_TOKEN=example_token
- - NEXT_PUBLIC_SITE_URL=http://localhost:3100
- - NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
- - NEXT_PUBLIC_AI_BACKEND_URL=http://localhost:8420
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:3100}
+ - NEXT_PUBLIC_MARBLE_API_URL=${NEXT_PUBLIC_MARBLE_API_URL:-https://api.marblecms.com}
+ - NEXT_PUBLIC_AI_BACKEND_URL=${NEXT_PUBLIC_AI_BACKEND_URL:-http://localhost:8420}
+ - NEXT_PUBLIC_OLLAMA_URL=${NEXT_PUBLIC_OLLAMA_URL:-http://localhost:11434}
- MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder}
- FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID}
- FREESOUND_API_KEY=${FREESOUND_API_KEY}
@@ -282,9 +290,9 @@ services:
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder}
- R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription}
- MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0}
- - NEXT_PUBLIC_WHISPER_SERVICE_URL=http://localhost:8421
- - NEXT_PUBLIC_TTS_SERVICE_URL=http://localhost:8422
- - NEXT_PUBLIC_IMAGE_SERVICE_URL=http://localhost:8423
+ - NEXT_PUBLIC_WHISPER_SERVICE_URL=${NEXT_PUBLIC_WHISPER_SERVICE_URL:-http://localhost:8421}
+ - NEXT_PUBLIC_TTS_SERVICE_URL=${NEXT_PUBLIC_TTS_SERVICE_URL:-http://localhost:8422}
+ - NEXT_PUBLIC_IMAGE_SERVICE_URL=${NEXT_PUBLIC_IMAGE_SERVICE_URL:-http://localhost:8423}
depends_on:
db:
condition: service_healthy
@@ -293,7 +301,7 @@ services:
ai-backend:
condition: service_healthy
healthcheck:
- test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 5
diff --git a/scripts/do-cloud-init.yaml b/scripts/do-cloud-init.yaml
new file mode 100644
index 0000000000..56600061ba
--- /dev/null
+++ b/scripts/do-cloud-init.yaml
@@ -0,0 +1,128 @@
+#cloud-config
+package_update: true
+package_upgrade: false
+
+packages:
+ - git
+ - curl
+ - ca-certificates
+ - gnupg
+ - jq
+ - ufw
+ - python3
+ - openssl
+
+write_files:
+ - path: /opt/opencut/deploy.sh
+ permissions: "0755"
+ content: |
+ #!/usr/bin/env bash
+ set -euo pipefail
+ exec > >(tee -a /var/log/opencut-deploy.log) 2>&1
+
+ echo "[opencut] deploy started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+ PUBLIC_IP="$(curl -fsS http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address)"
+ AUTH_SECRET="$(openssl rand -hex 32)"
+ APP_DIR=/opt/opencut/OpenCut-AI
+
+ if ! command -v docker >/dev/null 2>&1; then
+ curl -fsSL https://get.docker.com | sh
+ systemctl enable --now docker
+ fi
+
+ mkdir -p /opt/opencut
+ if [ ! -d "$APP_DIR/.git" ]; then
+ git clone --depth 1 https://github.com/Ekaanth/OpenCut-AI.git "$APP_DIR"
+ fi
+ cd "$APP_DIR"
+
+ # Bake public URLs into the Next.js build (NEXT_PUBLIC_* is compile-time)
+ sed -i \
+ -e "s|ENV NEXT_PUBLIC_SITE_URL=\"http://localhost:3000\"|ENV NEXT_PUBLIC_SITE_URL=\"http://${PUBLIC_IP}:3100\"|" \
+ -e "s|ENV NEXT_PUBLIC_AI_BACKEND_URL=\"http://localhost:8420\"|ENV NEXT_PUBLIC_AI_BACKEND_URL=\"http://${PUBLIC_IP}:8420\"|" \
+ apps/web/Dockerfile
+
+ cat > .env < tuple[str, str]:
+ """Map a 0–100 composite score to (grade letter, label)."""
+ if composite >= 85:
+ return "A", "Excellent"
+ if composite >= 70:
+ return "B", "Strong"
+ if composite >= 50:
+ return "C", "Average"
+ if composite >= 35:
+ return "D", "Below average"
+ return "F", "Needs work"
+
+
+class EngagementScore(BaseModel):
+ """Full engagement breakdown with composite score and suggestions."""
+
+ hook: HookScore = Field(default_factory=HookScore)
+ curiosity: CuriosityScore = Field(default_factory=CuriosityScore)
+ energy: EnergyScore = Field(default_factory=EnergyScore)
+ audio_sync: AudioSyncScore = Field(default_factory=AudioSyncScore)
+ face_presence: FacePresenceScore = Field(default_factory=FacePresenceScore)
+ emotional_arc: EmotionalArcScore = Field(default_factory=EmotionalArcScore)
+ virality: ViralityScore = Field(default_factory=ViralityScore)
+ suggestions: list[EnhancementSuggestion] = Field(default_factory=list)
+
+ def compute_composite(self) -> float:
+ """Weighted composite using configured engagement weights."""
+ from app.config import settings
+
+ return (
+ self.hook.composite * settings.ENGAGEMENT_HOOK_WEIGHT
+ + self.curiosity.composite * settings.ENGAGEMENT_CURIOSITY_WEIGHT
+ + self.virality.composite * settings.ENGAGEMENT_VIRALITY_WEIGHT
+ + self.energy.composite * settings.ENGAGEMENT_ENERGY_WEIGHT
+ + self.emotional_arc.composite * settings.ENGAGEMENT_EMOTION_WEIGHT
+ + self.audio_sync.composite * settings.ENGAGEMENT_AUDIO_SYNC_WEIGHT
+ + self.face_presence.composite * settings.ENGAGEMENT_FACE_WEIGHT
+ )
+
+ def to_response(self) -> dict:
+ """Serialize to the API shape expected by the web client."""
+ composite = round(min(100.0, max(0.0, self.compute_composite())), 1)
+ grade, grade_label = _grade_for_score(composite)
+ return {
+ "hook": self.hook.model_dump(),
+ "curiosity": self.curiosity.model_dump(),
+ "energy": self.energy.model_dump(),
+ "audio_sync": self.audio_sync.model_dump(),
+ "face_presence": self.face_presence.model_dump(),
+ "emotional_arc": self.emotional_arc.model_dump(),
+ "virality": self.virality.model_dump(),
+ "suggestions": [s.model_dump() for s in self.suggestions],
+ "composite": composite,
+ "grade": grade,
+ "grade_label": grade_label,
+ }
+
+ @property
+ def composite(self) -> float:
+ return self.compute_composite()
+
+
+# ── Request models ────────────────────────────────────────────────────
+
+
+class ScoreClipRequest(BaseModel):
+ """Score a single clip from transcript / audio / video paths."""
+
+ audio_path: str | None = None
+ video_path: str | None = None
+ transcript_text: str = ""
+ transcript_segments: list[dict] | None = None
+ start: float = 0.0
+ end: float = 0.0
+ title: str | None = None
+
+
+class ScoreBatchRequest(BaseModel):
+ """Batch scoring request for multiple clips."""
+
+ clips: list[ScoreClipRequest] = Field(default_factory=list)
+
+
+class ScoredClip(BaseModel):
+ """A detected clip with engagement score attached."""
+
+ index: int = 0
+ title: str = ""
+ start: float = 0.0
+ end: float = 0.0
+ transcript_preview: str = ""
+ tags: list[str] = Field(default_factory=list)
+ engagement: EngagementScore = Field(default_factory=EngagementScore)
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def duration(self) -> float:
+ return max(0.0, self.end - self.start)
+
+
+# ── YouTube / jobs ────────────────────────────────────────────────────
+
+
+class YouTubeVideoMeta(BaseModel):
+ """Metadata for an ingested YouTube video."""
+
+ video_id: str
+ title: str = "Untitled"
+ channel_name: str = "Unknown"
+ channel_id: str = ""
+ duration_seconds: int = 0
+ thumbnail_url: str = ""
+ upload_date: str = ""
+ view_count: int | None = None
+ is_live: bool = False
+ is_private: bool = False
+ warning: str | None = None
+
+
+class JobStatus(BaseModel):
+ """Background job status for YouTube / clip pipelines."""
+
+ job_id: str
+ status: str = "pending"
+ progress: float = 0.0
+ message: str = ""
+ result: dict | None = None
+ error: str | None = None
diff --git a/services/ai-backend/app/models/generation.py b/services/ai-backend/app/models/generation.py
new file mode 100644
index 0000000000..e852f31ddb
--- /dev/null
+++ b/services/ai-backend/app/models/generation.py
@@ -0,0 +1,36 @@
+"""Image generation and infographic request models."""
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ImageGenParams(BaseModel):
+ """Parameters for text-to-image generation (proxied to image-service)."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ prompt: str
+ negative_prompt: str = Field(default="", alias="negativePrompt")
+ width: int = 512
+ height: int = 512
+ steps: int = 20
+ guidance_scale: float = Field(default=7.5, alias="guidanceScale")
+ seed: int | None = None
+ model: str | None = None
+
+
+class EnhancePromptRequest(BaseModel):
+ """Request to expand a short prompt into a detailed diffusion prompt."""
+
+ prompt: str
+ style: str = "photorealistic"
+
+
+class InfographicRequest(BaseModel):
+ """Request to render a simple infographic overlay PNG."""
+
+ topic: str
+ data_points: list[dict] = Field(default_factory=list)
+ style: str = "modern"
+ width: int = 1080
+ height: int = 1080
+ background_color: tuple[int, int, int, int] | str = (0, 0, 0, 0)
diff --git a/services/ai-backend/app/models/transcription.py b/services/ai-backend/app/models/transcription.py
new file mode 100644
index 0000000000..ff18d0e754
--- /dev/null
+++ b/services/ai-backend/app/models/transcription.py
@@ -0,0 +1,34 @@
+"""Whisper transcription result models."""
+
+from pydantic import BaseModel, Field
+
+
+class TranscriptionWord(BaseModel):
+ """Word-level timestamp from faster-whisper."""
+
+ word: str
+ start: float
+ end: float
+ probability: float = 0.0
+
+
+class TranscriptionSegment(BaseModel):
+ """A transcribed segment with optional word timings."""
+
+ id: int
+ text: str
+ start: float
+ end: float
+ words: list[TranscriptionWord] = Field(default_factory=list)
+ avg_logprob: float = 0.0
+ no_speech_prob: float = 0.0
+ speaker: str | None = None
+
+
+class TranscriptionResult(BaseModel):
+ """Full transcription output."""
+
+ text: str = ""
+ segments: list[TranscriptionSegment] = Field(default_factory=list)
+ language: str = ""
+ duration: float = 0.0
diff --git a/services/ai-backend/app/routes/engagement.py b/services/ai-backend/app/routes/engagement.py
index 5666bcda9d..22d0e79406 100644
--- a/services/ai-backend/app/routes/engagement.py
+++ b/services/ai-backend/app/routes/engagement.py
@@ -9,7 +9,7 @@
import uuid
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from app.config import settings
from app.models.engagement import (
diff --git a/services/tts-service/requirements.lock b/services/tts-service/requirements.lock
index 16957e10d9..4f883e1f8b 100644
--- a/services/tts-service/requirements.lock
+++ b/services/tts-service/requirements.lock
@@ -408,7 +408,7 @@ torch==2.5.1
# coqui-tts
# coqui-tts-trainer
# encodec
-torchaudio==2.11.0
+torchaudio==2.5.1
# via
# coqui-tts
# encodec
diff --git a/services/tts-service/requirements.txt b/services/tts-service/requirements.txt
index d38e60f6c9..f52c470515 100644
--- a/services/tts-service/requirements.txt
+++ b/services/tts-service/requirements.txt
@@ -3,4 +3,5 @@ uvicorn[standard]==0.30.0
python-multipart==0.0.9
aiofiles==24.1.0
torch>=2.1.0,<2.6.0
+torchaudio==2.5.1
coqui-tts==0.24.2