Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes.
panel, so clips land under Discord, email or WhatsApp limits. The encoder
already knew how; this is the missing control, plus the achieved file size
(and a note when the planner had to downscale) after export.
- **OpenAI-compatible API endpoints.** Settings now takes a chat base URL
(Azure, OpenRouter, Groq, LM Studio, Ollama) and an optional separate
transcription URL, so a local Whisper server can sit next to a hosted LLM.
Structured-output calls fall back from `json_schema` to `json_object` (and
then a plain JSON completion) when the provider does not support OpenAI's
strict schema mode. `OPENAI_BASE_URL` still overrides Settings when set.

### Removed

Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,21 @@ Signing and notarisation are wanted; see
are configurable in Settings, including a cheaper legacy option.

**Can I run it against a local or non-OpenAI model?**
Not today. The client in `src/main/pipeline/openai.ts` targets the OpenAI REST
API. A pluggable endpoint would be a welcome contribution, and local Whisper is
the obvious first step.
Yes, if it speaks the OpenAI REST shape. Set the API base URL in Settings
(or `OPENAI_BASE_URL`) to Azure OpenAI, OpenRouter, Groq, LM Studio, Ollama,
or anything else with `/v1/chat/completions`. Transcription can point at a
separate local Whisper server (faster-whisper, whisper.cpp’s compatible
endpoint) — it must return **word-level timestamps**, because captions and
tighten-cuts depend on them. Bundled in-process Whisper (no server at all)
is still on the roadmap.

## Roadmap

Each of these is an open issue, so the discussion and the detail live there. Contributions very welcome.

- [Multi-language caption translation](https://github.com/JeremySNR/clip-forge/issues/49)
- [Manual zoom keyframes on the timeline](https://github.com/JeremySNR/clip-forge/issues/50)
- [OpenAI-compatible endpoints and local Whisper](https://github.com/JeremySNR/clip-forge/issues/46), so transcription can run free and offline
- Bundled on-device Whisper, so transcription needs no server at all
- Direct publishing and scheduling to socials (needs an audited TikTok/YouTube app)

Looking for somewhere to start? The [good first issues](https://github.com/JeremySNR/clip-forge/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) need no deep knowledge of the pipeline.
Expand Down
2 changes: 1 addition & 1 deletion src/main/pipeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function analyzeProject(
): Promise<Project> {
const apiKey = getApiKey()
if (!apiKey) {
throw new Error('No OpenAI API key configured. Add one in Settings before generating clips.')
throw new Error('No API key configured. Add one in Settings before generating clips.')
}
const settings = getModelPreferences()
const workDir = join(tmpdir(), 'clipforge', `job-${project.id}`)
Expand Down
165 changes: 139 additions & 26 deletions src/main/pipeline/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import { setTimeout as sleep } from 'node:timers/promises'
export const DEFAULT_OPENAI_API_BASE = 'https://api.openai.com/v1'

/**
* Resolve the OpenAI REST base URL from OPENAI_BASE_URL. Empty, whitespace,
* relative values like "/v1", and other non-absolute URLs fall back to the
* default — otherwise fetch() posts to "/v1/audio/transcriptions" and Whisper
* fails with HTTP 404 "Invalid URL".
* Resolve an OpenAI-compatible REST base URL. Empty, whitespace, relative
* values like "/v1", and other non-absolute URLs fall back to the default —
* otherwise fetch() posts to "/v1/audio/transcriptions" and Whisper fails
* with HTTP 404 "Invalid URL".
*
* `OPENAI_BASE_URL` still wins when set (cloud agents, CI). Settings values
* are applied through `configureOpenAiEndpoints`.
*/
export function resolveOpenAiApiBase(
envBase: string | undefined = process.env.OPENAI_BASE_URL
Expand All @@ -26,7 +29,7 @@ export function resolveOpenAiApiBase(
const trimmed = raw.replace(/\/$/, '')
if (!/^https?:\/\//i.test(trimmed)) {
console.warn(
`[clipforge] OPENAI_BASE_URL must be an absolute https URL (got ${JSON.stringify(raw)}); using ${DEFAULT_OPENAI_API_BASE}`
`[clipforge] API base URL must be an absolute http(s) URL (got ${JSON.stringify(raw)}); using ${DEFAULT_OPENAI_API_BASE}`
)
return DEFAULT_OPENAI_API_BASE
}
Expand All @@ -41,13 +44,45 @@ export function resolveOpenAiApiBase(
return trimmed
} catch {
console.warn(
`[clipforge] OPENAI_BASE_URL is invalid (${JSON.stringify(raw)}); using ${DEFAULT_OPENAI_API_BASE}`
`[clipforge] API base URL is invalid (${JSON.stringify(raw)}); using ${DEFAULT_OPENAI_API_BASE}`
)
return DEFAULT_OPENAI_API_BASE
}
}

const API_BASE = resolveOpenAiApiBase()
/**
* Settings-sourced bases. Env vars still take precedence so a cloud/CI
* `OPENAI_BASE_URL` cannot be silently overridden by a leftover Settings field.
*/
let settingsChatBase: string | undefined
let settingsTranscriptionBase: string | undefined

export function configureOpenAiEndpoints(opts: {
chatBase?: string
transcriptionBase?: string
}): void {
settingsChatBase = opts.chatBase?.trim() || undefined
settingsTranscriptionBase = opts.transcriptionBase?.trim() || undefined
}

/** Chat completions base (analysis, captions, B-roll, visual scoring). */
export function chatApiBase(): string {
return resolveOpenAiApiBase(process.env.OPENAI_BASE_URL || settingsChatBase)
}

/**
* Whisper transcription base. A dedicated transcription URL (env or Settings)
* wins, then the shared chat base, then the OpenAI default — so a local
* Whisper server can sit next to a hosted LLM.
*/
export function transcriptionApiBase(): string {
return resolveOpenAiApiBase(
process.env.OPENAI_TRANSCRIPTION_BASE_URL ||
process.env.OPENAI_BASE_URL ||
settingsTranscriptionBase ||
settingsChatBase
)
}

export class OpenAIError extends Error {
constructor(
Expand Down Expand Up @@ -128,7 +163,7 @@ async function raiseForStatus(res: Response, context: string): Promise<void> {
/* non-JSON error body */
}
if (res.status === 401) {
throw new OpenAIError('OpenAI rejected the API key. Check it in Settings.', 401)
throw new OpenAIError('The API rejected the API key. Check it in Settings.', 401)
}
throw new OpenAIError(`${context} failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`, res.status)
}
Expand Down Expand Up @@ -184,7 +219,7 @@ export async function transcribeAudioFile(
if (opts.contextPrompt) form.append('prompt', opts.contextPrompt)
if (opts.language && opts.language !== 'auto') form.append('language', opts.language)

const res = await fetch(`${API_BASE}/audio/transcriptions`, {
const res = await fetch(`${transcriptionApiBase()}/audio/transcriptions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
Expand Down Expand Up @@ -216,20 +251,91 @@ export async function chatJSON<T>(
): Promise<T> {
return withRetries(
async () => {
const res = await fetch(`${API_BASE}/chat/completions`, {
const content = await completeChatContent(
apiKey,
model,
messages,
schemaName,
schema,
signal
)
try {
return JSON.parse(content) as T
} catch {
throw new OpenAIError('Analysis returned invalid JSON')
}
},
{ signal }
)
}

function looksLikeUnsupportedFormat(err: unknown): boolean {
if (!(err instanceof OpenAIError) || (err.status !== 400 && err.status !== 422)) return false
const m = err.message.toLowerCase()
return /response_format|json_schema|json_object|strict|not supported|unknown parameter|unrecognized|invalid parameter/.test(
m
)
}

/**
* Compatible endpoints (Ollama, LM Studio, some Groq/OpenRouter models) often
* reject OpenAI's strict json_schema. Try that first, then json_object, then
* a bare completion with an instruction to return JSON.
*/
async function completeChatContent(
apiKey: string,
model: string,
messages: ChatMessage[],
schemaName: string,
schema: Record<string, unknown>,
signal?: AbortSignal
): Promise<string> {
const formats: Array<{ label: string; body: Record<string, unknown> }> = [
{
label: 'json_schema',
body: {
model,
messages,
response_format: {
type: 'json_schema',
json_schema: { name: schemaName, strict: true, schema }
}
}
},
{
label: 'json_object',
body: {
model,
messages,
response_format: { type: 'json_object' }
}
},
{
label: 'plain',
body: {
model,
messages: [
...messages,
{
role: 'user',
content:
'Return only a JSON object matching the requested schema. No markdown, no commentary.'
}
]
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback omits required JSON schema

Medium Severity

The json_object and plain fallbacks never send schema (or even a JSON-shape instruction on json_object). Callers rely on response_format.json_schema for field names, so a provider that rejects strict schema mode gets unstructured output and clip analysis can fail or return empty results.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8dd6cf. Configure here.

]

let lastError: unknown
for (const format of formats) {
try {
const res = await fetch(`${chatApiBase()}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
messages,
response_format: {
type: 'json_schema',
json_schema: { name: schemaName, strict: true, schema }
}
}),
body: JSON.stringify(format.body),
signal: withTimeout(CHAT_TIMEOUT_MS, signal)
})
await raiseForStatus(res, 'Analysis')
Expand All @@ -238,12 +344,19 @@ export async function chatJSON<T>(
}
const content = body.choices?.[0]?.message?.content
if (!content) throw new OpenAIError('Analysis returned an empty response')
try {
return JSON.parse(content) as T
} catch {
throw new OpenAIError('Analysis returned invalid JSON')
}
},
{ signal }
)
return extractJsonText(content)
} catch (err) {
lastError = err
if (signal?.aborted) throw err
if (!looksLikeUnsupportedFormat(err)) throw err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback skips accepted non-JSON replies

Medium Severity

Format fallback only runs on 400/422 messages that match looksLikeUnsupportedFormat. If a server accepts json_schema and returns 200 with prose or unparseable text — common on Ollama and LM Studio — completeChatContent returns immediately and chatJSON retries the same format instead of trying json_object or plain.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8dd6cf. Configure here.

}
}
throw lastError
}

/** Strip optional markdown fences some local models wrap JSON in. */
export function extractJsonText(content: string): string {
const trimmed = content.trim()
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
return fenced ? fenced[1].trim() : trimmed
}
2 changes: 1 addition & 1 deletion src/main/pipeline/wholeVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function captionWholeVideo(
// offline is allowed.
const apiKey = getApiKey()
if (!apiKey && !project.transcript) {
throw new Error('No OpenAI API key configured. Add one in Settings before transcribing.')
throw new Error('No API key configured. Add one in Settings before transcribing.')
}
const settings = getModelPreferences()
const workDir = join(tmpdir(), 'clipforge', `caption-${project.id}`)
Expand Down
30 changes: 30 additions & 0 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getGpuStatus } from './pipeline/encoders'
import { clearImportCookiesFile, getImportCookiesPath } from './cookies'
import { DEFAULT_BRAND_COLORS } from '@shared/captionStyles'
import { normalizeSizeTargetMb } from '@shared/uploadBudget'
import { configureOpenAiEndpoints } from './pipeline/openai'


interface StoredSettings {
Expand All @@ -23,6 +24,8 @@ interface StoredSettings {
/** ISO-639-1 language code forced on Whisper, or 'auto' to auto-detect. */
transcriptionLanguage: string
analysisModel: string
openaiBaseUrl: string
transcriptionBaseUrl: string
encoder: EncoderPreference
quality: QualityPreference
/** Megabyte cap for size-targeted export; null = quality-targeted encode. */
Expand Down Expand Up @@ -57,6 +60,8 @@ const DEFAULTS: StoredSettings = {
// theirs — or 'auto' — in Settings.
transcriptionLanguage: 'en',
analysisModel: 'gpt-5.4-mini',
openaiBaseUrl: '',
transcriptionBaseUrl: '',
encoder: 'auto',
quality: 'standard',
sizeTargetMb: null,
Expand All @@ -71,6 +76,18 @@ function settingsPath(): string {

let cache: StoredSettings | null = null

function applyEndpoints(s: StoredSettings): void {
configureOpenAiEndpoints({
chatBase: s.openaiBaseUrl,
transcriptionBase: s.transcriptionBaseUrl
})
}

function storedBaseUrl(raw: unknown): string {
if (typeof raw !== 'string') return ''
return raw.trim().replace(/\/$/, '')
}

function load(): StoredSettings {
if (cache) return cache
try {
Expand All @@ -79,6 +96,8 @@ function load(): StoredSettings {
cache = {
...DEFAULTS,
...parsed,
openaiBaseUrl: storedBaseUrl(parsed.openaiBaseUrl),
transcriptionBaseUrl: storedBaseUrl(parsed.transcriptionBaseUrl),
// Nested objects: merge so settings saved before new fields stay valid.
sizeTargetMb: normalizeSizeTargetMb(parsed.sizeTargetMb),
branding: {
Expand All @@ -88,17 +107,20 @@ function load(): StoredSettings {
},
brandVoice: { ...DEFAULT_BRAND_VOICE, ...(parsed.brandVoice ?? {}) }
}
applyEndpoints(cache)
return cache
}
} catch {
/* corrupted settings fall back to defaults */
}
cache = { ...DEFAULTS }
applyEndpoints(cache)
return cache
}

function persist(s: StoredSettings): void {
cache = s
applyEndpoints(s)
mkdirSync(app.getPath('userData'), { recursive: true })
writeFileSync(settingsPath(), JSON.stringify(s, null, 2), 'utf8')
}
Expand Down Expand Up @@ -132,6 +154,7 @@ export function getApiKey(): string {

export async function getSettings(): Promise<AppSettings> {
const s = load()
applyEndpoints(s)
const key = getApiKey()
return {
hasApiKey: key.length > 0,
Expand All @@ -140,6 +163,9 @@ export async function getSettings(): Promise<AppSettings> {
transcriptionModel: s.transcriptionModel,
transcriptionLanguage: s.transcriptionLanguage,
analysisModel: s.analysisModel,
openaiBaseUrl: s.openaiBaseUrl,
transcriptionBaseUrl: s.transcriptionBaseUrl,
openaiBaseUrlFromEnv: Boolean(process.env.OPENAI_BASE_URL?.trim()),
encoder: s.encoder,
quality: s.quality,
sizeTargetMb: s.sizeTargetMb,
Expand Down Expand Up @@ -209,6 +235,10 @@ export async function updateSettings(update: SettingsUpdate): Promise<AppSetting
if (update.analysisModel !== undefined && update.analysisModel.trim()) {
s.analysisModel = update.analysisModel.trim()
}
if (update.openaiBaseUrl !== undefined) s.openaiBaseUrl = storedBaseUrl(update.openaiBaseUrl)
if (update.transcriptionBaseUrl !== undefined) {
s.transcriptionBaseUrl = storedBaseUrl(update.transcriptionBaseUrl)
}
if (update.encoder !== undefined) s.encoder = update.encoder
if (update.quality !== undefined) s.quality = update.quality
if (update.sizeTargetMb !== undefined) s.sizeTargetMb = normalizeSizeTargetMb(update.sizeTargetMb)
Expand Down
Loading
Loading