diff --git a/AGENTS.md b/AGENTS.md index a7dca16..dcbe4f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 332dcf1..535a8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5012cb9..6c6a919 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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. diff --git a/src/main/index.ts b/src/main/index.ts index 862e63a..cab0b96 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -76,6 +76,9 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise { 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() } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2e2d957..34c55ae 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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' @@ -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 @@ -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) @@ -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) => { diff --git a/src/main/pipeline/render.ts b/src/main/pipeline/render.ts index d289151..f2fb5b4 100644 --- a/src/main/pipeline/render.ts +++ b/src/main/pipeline/render.ts @@ -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' @@ -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' @@ -541,7 +541,13 @@ export interface RenderJob { signal?: AbortSignal } -export async function renderClip(job: RenderJob): Promise { +export interface RenderResult { + outputPath: string + bytes: number + sizePlan: UploadEncodePlan | null +} + +export async function renderClip(job: RenderJob): Promise { const { clip, source, transcript } = job const quality = job.quality ?? 'standard' const start = clip.edit.start @@ -781,5 +787,6 @@ export async function renderClip(job: RenderJob): Promise { 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 } } diff --git a/src/main/settings.ts b/src/main/settings.ts index c17350a..0c68f48 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 { @@ -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 @@ -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: '' @@ -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 ?? {}), @@ -137,6 +142,7 @@ export async function getSettings(): Promise { analysisModel: s.analysisModel, encoder: s.encoder, quality: s.quality, + sizeTargetMb: s.sizeTargetMb, gpu: await getGpuStatus(), branding: s.branding, brandVoice: s.brandVoice, @@ -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). */ @@ -201,6 +211,7 @@ export async function updateSettings(update: SettingsUpdate): Promise void exportClip(clip.id)} onCancel={() => void cancelExport(clip.id)} /> @@ -199,6 +201,8 @@ export function ExportButton({ progress, outputPath, error, + bytes, + downscaled, onExport, onCancel }: { @@ -206,6 +210,8 @@ export function ExportButton({ progress: number outputPath?: string error?: string + bytes?: number + downscaled?: boolean onExport: () => void onCancel: () => void }): React.JSX.Element { @@ -229,13 +235,18 @@ export function ExportButton({ ) } if (status === 'done' && outputPath) { + const sizeLabel = bytes !== undefined ? ` · ${formatBytes(bytes)}` : '' return ( ) diff --git a/src/renderer/src/components/EditorScreen.tsx b/src/renderer/src/components/EditorScreen.tsx index f0ac4d6..22db8b2 100644 --- a/src/renderer/src/components/EditorScreen.tsx +++ b/src/renderer/src/components/EditorScreen.tsx @@ -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' /** @@ -512,16 +513,31 @@ export default function EditorScreen(): React.JSX.Element {
+
void exportClip(clip.id)} onCancel={() => void cancelExport(clip.id)} />
+ {entry?.status === 'done' && entry.downscaled && ( +

+ Scaled the frame down so the file would fit + {entry.sizeTargetBytes ? ` under ${formatBytes(entry.sizeTargetBytes)}` : ''}. +

+ )} + {entry?.status === 'done' && entry.overBudget && ( +

+ This edit is long for the size cap — the picture may look soft. A shorter trim would + hold up better. +

+ )} {entry?.status === 'error' && entry.error && (

{entry.error}

)} diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index 87e16a1..0652167 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -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, @@ -139,6 +140,7 @@ export default function SettingsModal(): React.JSX.Element { {SECTIONS.map(({ id, label, icon: Icon }) => (
+ + )} diff --git a/src/renderer/src/components/SizeTargetControls.tsx b/src/renderer/src/components/SizeTargetControls.tsx new file mode 100644 index 0000000..31b73bc --- /dev/null +++ b/src/renderer/src/components/SizeTargetControls.tsx @@ -0,0 +1,146 @@ +import { HardDrive } from 'lucide-react' +import { useStore } from '../store' +import { + MAX_SIZE_TARGET_MB, + MIN_SIZE_TARGET_MB, + normalizeSizeTargetMb +} from '@shared/uploadBudget' + +/** Common upload ceilings: Discord (8/10), email, WhatsApp-ish, Nitro, generous. */ +const SIZE_PRESETS_MB = [8, 10, 25, 50, 100] + +/** + * Fit-under-N-MB export control. The byte cap lives in Settings (same as + * quality/encoder) so Export all and the editor share it. `compact` is the + * editor sticky footer; the full layout lives on the Settings export tab. + */ +export default function SizeTargetControls({ + compact = false +}: { + compact?: boolean +}): React.JSX.Element { + const settings = useStore((s) => s.settings) + const saveSettings = useStore((s) => s.saveSettings) + const enabled = settings?.sizeTargetMb != null + const mb = settings?.sizeTargetMb ?? 25 + + const setEnabled = (on: boolean): void => { + void saveSettings({ sizeTargetMb: on ? mb : null }) + } + const setMb = (value: number): void => { + const next = normalizeSizeTargetMb(value) + if (next === null) return + void saveSettings({ sizeTargetMb: next }) + } + + if (compact) { + return ( +
+ + {enabled && ( +
+ setMb(Number(e.target.value))} + className="w-20 rounded-lg border border-surface-600 bg-surface-850 px-2.5 py-1.5 text-xs tabular-nums text-zinc-200 focus:border-white/25 focus:outline-none" + /> + MB +
+ {SIZE_PRESETS_MB.map((preset) => ( + + ))} +
+
+ )} +
+ ) + } + + return ( +
+ +

+ Encode once to land under a megabyte cap — Discord, email, WhatsApp, and anywhere that + rejects large files. Quality and GPU encoder are ignored while this is on: the cap + decides the bitrate, on CPU. +

+ + {enabled && ( + <> +
+ setMb(Number(e.target.value))} + className="w-24 rounded-xl border border-surface-600 bg-surface-850 px-3 py-2 text-sm tabular-nums text-zinc-200 focus:border-white/25 focus:outline-none" + /> + MB +
+
+ {SIZE_PRESETS_MB.map((preset) => ( + + ))} +
+ + )} +
+ ) +} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index c35264c..c744f89 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -16,7 +16,10 @@ export function formatTimecode(sec: number): string { export function formatBytes(bytes: number): string { if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB` - if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB` + if (bytes >= 1024 ** 2) { + const mb = bytes / 1024 ** 2 + return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} MB` + } return `${(bytes / 1024).toFixed(0)} KB` } diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index b41cf3a..4990d6c 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -60,6 +60,10 @@ export interface ExportEntry { progress: number outputPath?: string error?: string + bytes?: number + sizeTargetBytes?: number + downscaled?: boolean + overBudget?: boolean } @@ -400,7 +404,15 @@ export const useStore = create((set, get) => ({ set({ exports: { ...get().exports, - [clipId]: { status: 'done', progress: 1, outputPath: result.outputPath } + [clipId]: { + status: 'done', + progress: 1, + outputPath: result.outputPath, + bytes: result.bytes, + sizeTargetBytes: result.sizeTargetBytes, + downscaled: result.downscaled, + overBudget: result.overBudget + } } }) } catch (err) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 8024f2f..d4c2ad0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -254,6 +254,21 @@ export interface ExportProgress { export interface ExportResult { clipId: string outputPath: string + /** Size of the finished file, in bytes. */ + bytes: number + /** + * Byte cap the encode was planned against, when size-targeted export is on. + * Absent for ordinary quality-targeted renders. + */ + sizeTargetBytes?: number + /** True when the planner had to shrink the frame to hit the cap. */ + downscaled?: boolean + /** + * True when even the minimum scale could not reach a healthy bits-per-pixel + * at this duration — the file should still fit, but the picture will look + * worse than a shorter clip would. + */ + overBudget?: boolean } export type EncoderPreference = 'auto' | 'cpu' | 'gpu' @@ -390,6 +405,11 @@ export interface AppSettings { analysisModel: string encoder: EncoderPreference quality: QualityPreference + /** + * Hard file-size cap for exports, in megabytes. Null means ordinary + * quality-targeted encoding (the quality tier above applies). + */ + sizeTargetMb: number | null gpu: GpuEncoderStatus branding: BrandingSettings brandVoice: BrandVoiceSettings @@ -407,6 +427,8 @@ export interface SettingsUpdate { analysisModel?: string encoder?: EncoderPreference quality?: QualityPreference + /** Megabyte cap for size-targeted export; null/0 clears it. */ + sizeTargetMb?: number | null branding?: Partial brandVoice?: Partial importCookiesBrowser?: BrowserCookieSource diff --git a/src/shared/uploadBudget.ts b/src/shared/uploadBudget.ts index 6f49d83..a45f7de 100644 --- a/src/shared/uploadBudget.ts +++ b/src/shared/uploadBudget.ts @@ -124,3 +124,28 @@ export function planUploadEncode(input: UploadEncodeInput): UploadEncodePlan { overBudget } } + +/** Smallest useful cap: below 1 MB even a short clip starves the encoder. */ +export const MIN_SIZE_TARGET_MB = 1 +/** Safety ceiling so a typo cannot ask for a multi-gigabyte "cap". */ +export const MAX_SIZE_TARGET_MB = 2048 + +/** + * Normalise a user-entered megabyte cap. Null/blank/non-positive means the + * limit is off. Values are clamped and rounded to 0.1 MB so the Settings + * field and the editor panel agree on what gets stored. + */ +export function normalizeSizeTargetMb(raw: unknown): number | null { + if (raw === null || raw === undefined || raw === '') return null + const n = typeof raw === 'number' ? raw : Number(raw) + if (!Number.isFinite(n) || n <= 0) return null + const clamped = Math.min(MAX_SIZE_TARGET_MB, Math.max(MIN_SIZE_TARGET_MB, n)) + return Math.round(clamped * 10) / 10 +} + +/** Convert a stored megabyte cap to the byte ceiling the renderer expects. */ +export function sizeTargetBytesFromMb(mb: number | null | undefined): number | undefined { + const normalised = normalizeSizeTargetMb(mb) + if (normalised === null) return undefined + return Math.round(normalised * 1024 * 1024) +} diff --git a/tests/uploadBudget.test.ts b/tests/uploadBudget.test.ts index c9ac122..fcbf0b6 100644 --- a/tests/uploadBudget.test.ts +++ b/tests/uploadBudget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { planUploadEncode, type UploadEncodeInput } from '@shared/uploadBudget' +import { planUploadEncode, normalizeSizeTargetMb, sizeTargetBytesFromMb, type UploadEncodeInput } from '@shared/uploadBudget' const MB = 1024 * 1024 @@ -102,3 +102,30 @@ describe('planUploadEncode', () => { expect(plan.videoKbps).toBeGreaterThanOrEqual(150) }) }) + +describe('normalizeSizeTargetMb', () => { + it('treats blank and non-positive values as off', () => { + expect(normalizeSizeTargetMb(null)).toBeNull() + expect(normalizeSizeTargetMb(undefined)).toBeNull() + expect(normalizeSizeTargetMb('')).toBeNull() + expect(normalizeSizeTargetMb(0)).toBeNull() + expect(normalizeSizeTargetMb(-5)).toBeNull() + expect(normalizeSizeTargetMb('nope')).toBeNull() + }) + + it('clamps to the 1–2048 MB window and rounds to a tenth', () => { + expect(normalizeSizeTargetMb(0.4)).toBe(1) + expect(normalizeSizeTargetMb(25)).toBe(25) + expect(normalizeSizeTargetMb(25.16)).toBe(25.2) + expect(normalizeSizeTargetMb(9999)).toBe(2048) + expect(normalizeSizeTargetMb('10')).toBe(10) + }) +}) + +describe('sizeTargetBytesFromMb', () => { + it('converts a stored cap to the byte ceiling the renderer uses', () => { + expect(sizeTargetBytesFromMb(null)).toBeUndefined() + expect(sizeTargetBytesFromMb(10)).toBe(10 * 1024 * 1024) + expect(sizeTargetBytesFromMb(0)).toBeUndefined() + }) +})