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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ClipForge is a single **Electron + Vite + React + TypeScript** desktop app (npm,
`xvfb-run -a --server-args="-screen 0 1600x1000x24" npx electron . --no-sandbox --disable-gpu`
(Requires a prior `npm run build` so `out/` exists.) `npm run dev` also works but expects a display.
- Harmless `Failed to connect to the bus` (DBus) and GPU warnings are expected under Xvfb and can be ignored.
- `scripts/smoke-test.sh [out-dir]` is the fastest end-to-end GUI check: it builds, seeds a demo project via `scripts/seed-demo.ts`, launches under Xvfb with `CLIPFORGE_SMOKE` set, and writes `home.png`, `clips.png`, `editor.png`, `setup-clips.png`, `setup-caption-video.png`, `editor-caption-video.png` and `settings.png` screenshots. The walk assumes a freshly seeded demo project (the script seeds one every run), and it runs "caption whole video" for real using the seeded transcript, so it makes no API calls. Set `CLIPFORGE_SMOKE` to an output dir to trigger this auto-screenshot-and-exit mode.
- `scripts/smoke-test.sh [out-dir]` is the fastest end-to-end GUI check: it builds, seeds a demo project via `scripts/seed-demo.ts`, launches under Xvfb with `CLIPFORGE_SMOKE` set, and writes `home.png`, `clips.png`, `editor.png`, `setup-clips.png`, `setup-caption-video.png`, `editor-caption-video.png`, `settings.png` and `settings-export.png` screenshots. The walk assumes a freshly seeded demo project (the script seeds one every run), and it runs "caption whole video" for real using the seeded transcript, so it makes no API calls. Set `CLIPFORGE_SMOKE` to an output dir to trigger this auto-screenshot-and-exit mode.

### Tests / lint
- `npm test` (vitest), `npm run typecheck` and `npm run lint` (eslint) are the static gates, and CI enforces all three on every push. Run them before proposing a change.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes.

## [Unreleased]

### Added

- **"Fit under N MB" export.** A size cap in Settings and the editor export
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.

### Removed

- **The WorkVivo posting integration.** It was specific to one organisation's
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Typical cost: **~$0.36/hour of video** for Whisper transcription plus a few cent

**Shipping them**

- **Export** H.264/AAC MP4s with burned-in captions. Loudness-normalised to -14 LUFS, gentle audio tail fade, three quality tiers, NVIDIA NVENC GPU encoding with automatic CPU fallback.
- **Export** H.264/AAC MP4s with burned-in captions. Loudness-normalised to -14 LUFS, gentle audio tail fade, three quality tiers, NVIDIA NVENC GPU encoding with automatic CPU fallback. Optionally encode once to fit under a megabyte cap (Discord, email, WhatsApp).
- **AI post captions.** One click writes a scroll-stopping TikTok/Reels/Shorts caption (hook-first line, one engagement driver, niche hashtags). Copy it and jump straight to TikTok Studio upload.
- **In-app updates.** Packaged builds download and install updates themselves. Source checkouts update with one click (pull, rebuild, relaunch).

Expand Down Expand Up @@ -227,7 +227,6 @@ Each of these is an open issue, so the discussion and the detail live there. Con
- [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
- [Size-targeted export](https://github.com/JeremySNR/clip-forge/issues/48) ("fit under N MB"), where the encoder work is already done
- 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
3 changes: 3 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise<void> {
await shot('editor-caption-video')
await click('[data-testid="settings-button"]')
await shot('settings')
await click('[data-testid="settings-nav-export"]')
await sleep(400)
await shot('settings-export')
app.quit()
}

Expand Down
18 changes: 16 additions & 2 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
SettingsUpdate
} from '@shared/types'
import { VIDEO_EXTENSIONS } from '@shared/video'
import { sizeTargetBytesFromMb } from '@shared/uploadBudget'
import { analyzeProject, createProject, createProjectFromUrl } from './pipeline'
import { captionWholeVideo } from './pipeline/wholeVideo'
import { downloadGpuFfmpeg } from './pipeline/encoders'
Expand Down Expand Up @@ -216,17 +217,19 @@ export function registerIpcHandlers(): void {
const suffix = clip.edit.aspect === 'original' ? '' : ` (${clip.edit.aspect.replace(':', 'x')})`
const outputPath = uniqueOutputPath(opts.outputDir, `${sanitizeFileName(clip.title)}${suffix}`)
const prefs = getExportPreferences()
const sizeTargetBytes = sizeTargetBytesFromMb(prefs.sizeTargetMb)
const branding = getBrandingSettings()
const controller = new AbortController()
runningExports.set(clip.id, controller)
try {
await renderClip({
const rendered = await renderClip({
clip,
source: project.video,
transcript: project.transcript,
outputPath,
encoder: prefs.encoder,
quality: prefs.quality,
sizeTargetBytes,
branding:
branding.enabled && branding.imagePath && existsSync(branding.imagePath)
? branding
Expand All @@ -239,6 +242,18 @@ export function registerIpcHandlers(): void {
}
}
})
return {
clipId: clip.id,
outputPath: rendered.outputPath,
bytes: rendered.bytes,
...(sizeTargetBytes !== undefined
? {
sizeTargetBytes,
downscaled: rendered.sizePlan?.downscaled ?? false,
overBudget: rendered.sizePlan?.overBudget ?? false
}
: {})
}
} catch (err) {
if (controller.signal.aborted) {
await rm(outputPath, { force: true }).catch(() => undefined)
Expand All @@ -248,7 +263,6 @@ export function registerIpcHandlers(): void {
} finally {
runningExports.delete(clip.id)
}
return { clipId: clip.id, outputPath }
})

ipcMain.handle('clip:cancelExport', async (_e, clipId: string) => {
Expand Down
15 changes: 11 additions & 4 deletions src/main/pipeline/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { writeFile, mkdir, rm } from 'node:fs/promises'
import { writeFile, mkdir, rm, stat } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
Expand All @@ -19,7 +19,7 @@ import { focusPanDuration, focusSnaps } from '@shared/focusTrack'
import { clipAllowsAutoZoom } from '@shared/contentType'
import { resolveCaptionStyle } from '@shared/captionStyles'
import { computeZoomEvents, fitZoomEvents, remapZoomEvents, type ZoomEvent } from '@shared/zoom'
import { planUploadEncode } from '@shared/uploadBudget'
import { planUploadEncode, type UploadEncodePlan } from '@shared/uploadBudget'
import { FFMPEG_PATH, runFfmpegWith } from './ffmpeg'
import { buildAss, fontsDir } from './captions'
import { fontMetricsForFamily } from '../fonts'
Expand Down Expand Up @@ -541,7 +541,13 @@ export interface RenderJob {
signal?: AbortSignal
}

export async function renderClip(job: RenderJob): Promise<string> {
export interface RenderResult {
outputPath: string
bytes: number
sizePlan: UploadEncodePlan | null
}

export async function renderClip(job: RenderJob): Promise<RenderResult> {
const { clip, source, transcript } = job
const quality = job.quality ?? 'standard'
const start = clip.edit.start
Expand Down Expand Up @@ -781,5 +787,6 @@ export async function renderClip(job: RenderJob): Promise<string> {
temps.filter((p): p is string => p !== null).map((p) => rm(p, { force: true }).catch(() => undefined))
)
}
return job.outputPath
const bytes = (await stat(job.outputPath)).size
return { outputPath: job.outputPath, bytes, sizePlan: plan }
}
17 changes: 14 additions & 3 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import { getGpuStatus } from './pipeline/encoders'
import { clearImportCookiesFile, getImportCookiesPath } from './cookies'
import { DEFAULT_BRAND_COLORS } from '@shared/captionStyles'
import { normalizeSizeTargetMb } from '@shared/uploadBudget'


interface StoredSettings {
Expand All @@ -24,6 +25,8 @@ interface StoredSettings {
analysisModel: string
encoder: EncoderPreference
quality: QualityPreference
/** Megabyte cap for size-targeted export; null = quality-targeted encode. */
sizeTargetMb: number | null
branding: BrandingSettings
brandVoice: BrandVoiceSettings
importCookiesBrowser: BrowserCookieSource
Expand Down Expand Up @@ -56,6 +59,7 @@ const DEFAULTS: StoredSettings = {
analysisModel: 'gpt-5.4-mini',
encoder: 'auto',
quality: 'standard',
sizeTargetMb: null,
branding: DEFAULT_BRANDING,
brandVoice: DEFAULT_BRAND_VOICE,
importCookiesBrowser: ''
Expand All @@ -76,6 +80,7 @@ function load(): StoredSettings {
...DEFAULTS,
...parsed,
// Nested objects: merge so settings saved before new fields stay valid.
sizeTargetMb: normalizeSizeTargetMb(parsed.sizeTargetMb),
branding: {
...DEFAULT_BRANDING,
...(parsed.branding ?? {}),
Expand Down Expand Up @@ -137,6 +142,7 @@ export async function getSettings(): Promise<AppSettings> {
analysisModel: s.analysisModel,
encoder: s.encoder,
quality: s.quality,
sizeTargetMb: s.sizeTargetMb,
gpu: await getGpuStatus(),
branding: s.branding,
brandVoice: s.brandVoice,
Expand Down Expand Up @@ -167,10 +173,14 @@ export function getBrandVoiceSettings(): BrandVoiceSettings {
return load().brandVoice
}

/** Synchronous access to the stored encoder/quality preferences. */
export function getExportPreferences(): { encoder: EncoderPreference; quality: QualityPreference } {
/** Synchronous access to the stored encoder/quality/size-cap preferences. */
export function getExportPreferences(): {
encoder: EncoderPreference
quality: QualityPreference
sizeTargetMb: number | null
} {
const s = load()
return { encoder: s.encoder, quality: s.quality }
return { encoder: s.encoder, quality: s.quality, sizeTargetMb: s.sizeTargetMb }
}

/** Synchronous access to the stored model preferences (no GPU probe). */
Expand Down Expand Up @@ -201,6 +211,7 @@ export async function updateSettings(update: SettingsUpdate): Promise<AppSetting
}
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)
if (update.branding !== undefined) {
const b = update.branding
s.branding = {
Expand Down
17 changes: 14 additions & 3 deletions src/renderer/src/components/ClipsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
X
} from 'lucide-react'
import { useStore } from '../store'
import { formatDuration } from '../lib/format'
import { formatBytes, formatDuration } from '../lib/format'
import ScoreBadge from './ScoreBadge'
import MissingSourceBanner from './MissingSourceBanner'
import type { Clip } from '@shared/types'
Expand Down Expand Up @@ -185,6 +185,8 @@ function ClipCard({ clip, rank }: { clip: Clip; rank: number }): React.JSX.Eleme
progress={entry?.progress ?? 0}
outputPath={entry?.outputPath}
error={entry?.error}
bytes={entry?.bytes}
downscaled={entry?.downscaled}
onExport={() => void exportClip(clip.id)}
onCancel={() => void cancelExport(clip.id)}
/>
Expand All @@ -199,13 +201,17 @@ export function ExportButton({
progress,
outputPath,
error,
bytes,
downscaled,
onExport,
onCancel
}: {
status?: 'exporting' | 'done' | 'error'
progress: number
outputPath?: string
error?: string
bytes?: number
downscaled?: boolean
onExport: () => void
onCancel: () => void
}): React.JSX.Element {
Expand All @@ -229,13 +235,18 @@ export function ExportButton({
)
}
if (status === 'done' && outputPath) {
const sizeLabel = bytes !== undefined ? ` · ${formatBytes(bytes)}` : ''
return (
<button
onClick={() => void window.clipforge.showItemInFolder(outputPath)}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-emerald-500/15 px-3 py-2 text-xs font-medium text-emerald-400 transition hover:bg-emerald-500/25"
title={outputPath}
title={
downscaled
? `${outputPath} — scaled down to fit the size limit`
: outputPath
}
>
<Check size={13} /> Saved
<Check size={13} /> Saved{sizeLabel}
<FolderOpen size={13} />
</button>
)
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/src/components/EditorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ import TrimBar from './TrimBar'
import ScoreBadge from './ScoreBadge'
import TranscriptEditor from './TranscriptEditor'
import { ExportButton } from './ClipsScreen'
import SizeTargetControls from './SizeTargetControls'
import { CAPTION_STYLES, resolveCaptionStyle } from '@shared/captionStyles'
import { formatTimecode } from '../lib/format'
import { formatBytes, formatTimecode } from '../lib/format'
import type { AspectRatio, BrollItem, BrollMode, Clip, FramingMode, ReframeMode } from '@shared/types'

/**
Expand Down Expand Up @@ -512,16 +513,31 @@ export default function EditorScreen(): React.JSX.Element {
<ShareSection clip={clip} />

<div className="sticky bottom-0 -mx-5 -mb-5 border-t border-white/[0.06] bg-surface-900/80 p-4 backdrop-blur-xl">
<SizeTargetControls compact />
<div className="flex">
<ExportButton
status={entry?.status}
progress={entry?.progress ?? 0}
outputPath={entry?.outputPath}
error={entry?.error}
bytes={entry?.bytes}
downscaled={entry?.downscaled}
onExport={() => void exportClip(clip.id)}
onCancel={() => void cancelExport(clip.id)}
/>
</div>
{entry?.status === 'done' && entry.downscaled && (
<p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
Scaled the frame down so the file would fit
{entry.sizeTargetBytes ? ` under ${formatBytes(entry.sizeTargetBytes)}` : ''}.
</p>
)}
{entry?.status === 'done' && entry.overBudget && (
<p className="mt-2 text-[11px] leading-relaxed text-amber-400">
This edit is long for the size cap — the picture may look soft. A shorter trim would
hold up better.
</p>

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.

Size cap can be exceeded

Medium Severity

The overBudget note and ExportResult comment say the file still meets the cap. The encoder’s 150 kbps floor can push long edits over sizeTargetBytes, so a whole-video export at an 8 MB Discord preset can finish around 18 MB and get rejected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8343bcb. Configure here.

)}
{entry?.status === 'error' && entry.error && (
<p className="mt-2 text-[11px] leading-relaxed text-red-400">{entry.error}</p>
)}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
MessageSquareQuote,
} from 'lucide-react'
import { useStore } from '../store'
import SizeTargetControls from './SizeTargetControls'
import { DEFAULT_BRAND_COLORS, resolveCaptionStyle } from '@shared/captionStyles'
import type {
BrandColors,
Expand Down Expand Up @@ -139,6 +140,7 @@ export default function SettingsModal(): React.JSX.Element {
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button
key={id}
data-testid={`settings-nav-${id}`}
onClick={() => setSection(id)}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition ${
section === id
Expand Down Expand Up @@ -336,6 +338,8 @@ export default function SettingsModal(): React.JSX.Element {
))}
</div>
</div>

<SizeTargetControls />
</div>
)}

Expand Down
Loading
Loading