diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts
index 85585556..86097065 100644
--- a/src/components/canvas/players/html5-player.ts
+++ b/src/components/canvas/players/html5-player.ts
@@ -119,7 +119,7 @@ export class Html5Player extends Player {
private decodedFrames: Map = new Map();
private decodeInFlight: number | null = null;
private captureInFlight: Promise | null = null;
- private captureFps: number = 30;
+ private captureFps: number = 25;
private mode: Html5Mode = "editing";
private contentHash: string | null = null;
private capturedHash: string | null = null;
@@ -293,7 +293,7 @@ export class Html5Player extends Player {
const height = this.clipConfiguration.height || this.edit.size.height;
this.renderedWidth = width;
this.renderedHeight = height;
- this.captureFps = this.edit.getOutputFps() || 30;
+ this.captureFps = this.edit.getOutputFps();
this.contentHash = await this.hashAsset();
const iframe = createOffscreenIframe(width, height);
diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts
index ca46cee2..523589d6 100644
--- a/src/core/export/export-coordinator.ts
+++ b/src/core/export/export-coordinator.ts
@@ -72,7 +72,7 @@ export class ExportCoordinator {
}
async exportBlob(options: ExportBlobOptions = {}): Promise {
- return this.encode(this.edit.getEdit().output?.fps ?? 30, options);
+ return this.encode(this.edit.getOutputFps(), options);
}
async export(filename = "shotstack-export.mp4", fps?: number): Promise {
@@ -80,7 +80,7 @@ export class ExportCoordinator {
this.progressUI.create();
try {
- const blob = await this.encode(fps ?? this.edit.getEdit().output?.fps ?? 30, {
+ const blob = await this.encode(fps ?? this.edit.getOutputFps(), {
onProgress: (fraction, stage) => this.progressUI.update(fraction * 100, 100, stage)
});
@@ -282,7 +282,7 @@ export class ExportCoordinator {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.code === "KeyE" && (e.metaKey || e.ctrlKey) && !exporting.has(this.edit)) {
e.preventDefault();
- this.export("shotstack-export.mp4", this.edit.getEdit().output?.fps || 30).catch(err => console.error("Export failed:", err));
+ this.export("shotstack-export.mp4", this.edit.getOutputFps()).catch(err => console.error("Export failed:", err));
}
};
diff --git a/src/core/output-settings-manager.ts b/src/core/output-settings-manager.ts
index 1d99ceee..9bb32059 100644
--- a/src/core/output-settings-manager.ts
+++ b/src/core/output-settings-manager.ts
@@ -130,7 +130,9 @@ export class OutputSettingsManager {
}
getFps(): number {
- return this.edit.getResolvedEdit()?.output?.fps ?? 30;
+ const output = this.edit.getResolvedEdit()?.output;
+ // The render's defaults, so the preview and the render step through the same frames.
+ return output?.fps ?? (output?.resolution === "preview" ? 12 : 25);
}
// ─── Format ───────────────────────────────────────────────────────────────
diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts
index 371c4a2e..c2e03206 100644
--- a/tests/export-coordinator.test.ts
+++ b/tests/export-coordinator.test.ts
@@ -80,6 +80,7 @@ function createHarness() {
tracks: [] as Array>,
getEdit: jest.fn(() => ({ output: { fps: 2 } as { fps: number; mute?: boolean } })),
getOutputFormat: jest.fn(() => "mp4"),
+ getOutputFps: jest.fn(() => 2),
getResolvedEdit: () => ({ timeline: { tracks: [] }, output: { format: "mp4" } }),
getFontUrlByFamilyAndWeight: () => null,
whenSettled: jest.fn(() => Promise.resolve()),
diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts
index b30c3816..522587b9 100644
--- a/tests/html5-player.test.ts
+++ b/tests/html5-player.test.ts
@@ -585,7 +585,7 @@ const frameMarkup = (blob: Blob) =>
function createStillPlayer() {
const created = createPlayer();
Object.assign(created.edit, { getCanvas: () => null });
- Object.assign(created.player, { getLength: () => 2 });
+ Object.assign(created.player, { getLength: () => 2, captureFps: 30 });
jest.spyOn(HTMLIFrameElement.prototype, "srcdoc", "set").mockImplementation(function navigate(this: HTMLIFrameElement) {
setTimeout(() => {
const doc = this.contentDocument!;
diff --git a/tests/output-resolution.test.ts b/tests/output-resolution.test.ts
index 0774c818..0d575e50 100644
--- a/tests/output-resolution.test.ts
+++ b/tests/output-resolution.test.ts
@@ -122,6 +122,22 @@ describe("Output Resolution and AspectRatio", () => {
let edit: Edit;
let emitSpy: jest.SpyInstance;
+ describe("output fps", () => {
+ it("defaults to the render's 25 fps", () => {
+ expect(edit.getOutputFps()).toBe(25);
+ });
+
+ it("defaults to the render's 12 fps at preview resolution", async () => {
+ await edit.setOutputResolution("preview");
+ expect(edit.getOutputFps()).toBe(12);
+ });
+
+ it("uses the edit's fps when set", async () => {
+ await edit.setOutputFps(30);
+ expect(edit.getOutputFps()).toBe(30);
+ });
+ });
+
beforeEach(async () => {
edit = new Edit(createMinimalEdit());
await edit.load();