Skip to content
Draft
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
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ speed regions, wallpaper/padding, annotations, cursor rendering, webcam layouts.

```bash
openscreen export demo.openscreen # format/quality from the project
openscreen export demo.openscreen -o out.mp4 --quality source
openscreen export demo.openscreen -o out.mp4 --quality 4k
openscreen export demo.openscreen -o out.gif --gif-fps 20 --gif-size large
openscreen export demo.openscreen --json | while read line; do ...; done
```
Expand All @@ -152,7 +152,7 @@ openscreen export demo.openscreen --json | while read line; do ...; done
|---|---|
| `-o, --out <path>` | Output file; extension picks the format. Default: next to the project |
| `--format <mp4\|gif>` | Override the project's stored format |
| `--quality <medium\|good\|source>` | MP4 quality |
| `--quality <medium\|good\|4k\|source>` | MP4 quality |
| `--gif-fps <15\|20\|25\|30>`, `--gif-size <medium\|large\|original>` | GIF settings |
| `--auto-zoom` | Add automatic zooms from cursor telemetry before rendering — the same dwell-detection engine as the editor's magic wand. Existing zoom regions are kept; suggestions never overlap them |
| `--audio <file>` | Mix a voiceover file into the MP4 (mp3/wav/m4a — anything Chromium can decode; AIFF is not supported) |
Expand Down
7 changes: 7 additions & 0 deletions electron/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ describe("parseCliArgs", () => {
});
});

it("accepts 4k as an MP4 export quality", () => {
expect(parse(["export", "demo.openscreen", "--quality", "4k"])).toMatchObject({
kind: "export",
quality: "4k",
});
});

it("rejects a --format that conflicts with the --out extension", () => {
const cmd = parse(["export", "a.openscreen", "-o", "x.mp4", "--format", "gif"]);
expect(cmd).toMatchObject({ kind: "error" });
Expand Down
6 changes: 3 additions & 3 deletions electron/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Usage:
Export options:
-o, --out <path> Output file (.mp4 or .gif). Default: next to the project file
--format <mp4|gif> Override the format stored in the project
--quality <medium|good|source>
--quality <medium|good|4k|source>
MP4 quality (default: from project)
--gif-fps <15|20|25|30> GIF frame rate (default: from project)
--gif-size <medium|large|original>
Expand Down Expand Up @@ -203,8 +203,8 @@ function parseExport(args: string[], cwd: string): CliCommand {
}
case "--quality": {
const [value, next] = takeValue(args, i, arg);
if (value !== "medium" && value !== "good" && value !== "source") {
throw new Error(`--quality must be medium, good or source, got "${value}"`);
if (value !== "medium" && value !== "good" && value !== "4k" && value !== "source") {
throw new Error(`--quality must be medium, good, 4k or source, got "${value}"`);
}
request.quality = value;
i = next;
Expand Down
24 changes: 24 additions & 0 deletions src/components/ai-edition/ExportDialog.showInFolder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock("@/native/sceneDescription", () => ({
import { toast } from "sonner";
import { I18nProvider } from "@/contexts/I18nContext";
import { type AxcutDocument, axcutSchemaVersion } from "@/lib/ai-edition/schema";
import { exportMultiNative } from "@/native";
import { ExportDialog } from "./ExportDialog";

type ElectronAPI = Window["electronAPI"];
Expand Down Expand Up @@ -151,4 +152,27 @@ describe("ExportDialog done panel — show in folder", () => {
);
expect(toast.error).not.toHaveBeenCalled();
});

it("offers a 4K button and exports a landscape project at 3840 × 2160", async () => {
render(
<I18nProvider>
<ExportDialog open={true} onClose={noop} document={DOC} />
</I18nProvider>,
);

const fourK = screen.getByRole("button", { name: /4k/i });
expect(fourK).toHaveTextContent("3840 × 2160");
expect(fourK).toHaveTextContent(/upscale/i);
fireEvent.click(fourK);
fireEvent.click(screen.getByRole("button", { name: /export mp4/i }));

await waitFor(() =>
expect(vi.mocked(exportMultiNative)).toHaveBeenCalledWith(
expect.any(Array),
SAVED_PATH,
expect.any(String),
expect.objectContaining({ width: 3840, height: 2160 }),
),
);
});
});
3 changes: 2 additions & 1 deletion src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const QUALITY_OPTIONS: Array<{
}> = [
{ value: "medium", labelKey: "exportQuality.low" },
{ value: "good", labelKey: "exportQuality.medium" },
{ value: "4k", labelKey: "exportQuality.ultra" },
{ value: "source", labelKey: "exportQuality.high" },
];

Expand Down Expand Up @@ -409,7 +410,7 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
>
{t("exportDialog.quality")}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8 }}>
{QUALITY_OPTIONS.map((q) => (
<button
type="button"
Expand Down
4 changes: 4 additions & 0 deletions src/components/video-editor/projectPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ describe("projectPersistence media compatibility", () => {
expect(normalizeProjectEditor({ webcamMirrored: "yes" as never }).webcamMirrored).toBe(false);
});

it("preserves 4K as a valid export quality", () => {
expect(normalizeProjectEditor({ exportQuality: "4k" }).exportQuality).toBe("4k");
});

it("normalizes blur region type and mosaic block size safely", () => {
const editor = normalizeProjectEditor({
annotationRegions: [
Expand Down
4 changes: 3 additions & 1 deletion src/components/video-editor/projectPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: normalizedWebcamPosition,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
editor.exportQuality === "medium" ||
editor.exportQuality === "4k" ||
editor.exportQuality === "source"
? editor.exportQuality
: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "دقة التصدير",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"title": "Export resolution",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Resolución de exportación",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Résolution d'export",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/it/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Risoluzione esportazione",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Originale"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja-JP/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "書き出し解像度",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko-KR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "내보내기 해상도",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Qualidade de Exportação",
"low": "Baixa",
"medium": "Média",
"ultra": "4K",
"high": "Alta"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Разрешение экспорта",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/tr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Dışa aktarma çözünürlüğü",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/vi/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "Độ phân giải xuất",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"title": "导出分辨率",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
"title": "匯出解析度",
"low": "720p",
"medium": "1080p",
"ultra": "4K",
"high": "Source"
},
"gifSettings": {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/exporter/mp4ExportSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ describe("calculateMp4ExportSettings", () => {
});
});

it("exports 4K at a 2160px short side while preserving the project aspect ratio", () => {
expect(
calculateMp4ExportSettings({
quality: "4k",
sourceWidth: 1920,
sourceHeight: 1080,
aspectRatioValue: 16 / 9,
}),
).toMatchObject({ width: 3840, height: 2160, bitrate: 80_000_000 });

expect(
calculateMp4ExportSettings({
quality: "4k",
sourceWidth: 1080,
sourceHeight: 1920,
aspectRatioValue: 9 / 16,
}),
).toMatchObject({ width: 2160, height: 3840, bitrate: 80_000_000 });
});

it("does not call letterbox rows an upscale (1920x1032 window capture, 16:9 project)", () => {
const source = { width: 1920, height: 1032 };
const tier = (quality: "medium" | "good" | "source") =>
Expand Down
11 changes: 10 additions & 1 deletion src/lib/exporter/mp4ExportSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function wouldUpscale(output: Dims, source: Dims): boolean {

const MEDIUM_SHORT_SIDE = 720;
const HIGH_SHORT_SIDE = 1080;
const FOUR_K_SHORT_SIDE = 2160;

function even(value: number) {
return Math.floor(value / 2) * 2;
Expand Down Expand Up @@ -116,7 +117,7 @@ function calculateSourceDimensions(
function calculateBitrate(width: number, height: number, quality: ExportQuality) {
const totalPixels = width * height;

if (quality === "source") {
if (quality === "source" || quality === "4k") {
if (totalPixels > 2560 * 1440) return 80_000_000;
if (totalPixels > 1920 * 1080) return 50_000_000;
return 30_000_000;
Expand Down Expand Up @@ -154,6 +155,14 @@ export function calculateMp4ExportSettings({
};
}

if (quality === "4k") {
const dimensions = calculateDimensionsForShortSide(FOUR_K_SHORT_SIDE, aspectRatioValue);
return {
...dimensions,
bitrate: calculateBitrate(dimensions.width, dimensions.height, quality),
};
}

const sourceDimensions = calculateSourceDimensions(sourceWidth, sourceHeight, aspectRatioValue);
return {
...sourceDimensions,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/exporter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface VideoFrameData {
duration: number; // in microseconds
}

export type ExportQuality = "medium" | "good" | "source";
export type ExportQuality = "medium" | "good" | "4k" | "source";

// GIF Export Types
export type ExportFormat = "mp4" | "gif";
Expand Down
6 changes: 6 additions & 0 deletions src/lib/userPreferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ describe("user preferences", () => {
expect(loadUserPreferences().trayLayout).toBe("vertical");
});

it("persists the 4K export quality preference", () => {
saveUserPreferences({ exportQuality: "4k" });

expect(loadUserPreferences().exportQuality).toBe("4k");
});

it("falls back to the default tray layout for invalid stored values", () => {
localStorage.setItem("openscreen_user_preferences", JSON.stringify({ trayLayout: "diagonal" }));

Expand Down
1 change: 1 addition & 0 deletions src/lib/userPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function loadUserPreferences(): UserPreferences {
exportQuality:
raw.exportQuality === "medium" ||
raw.exportQuality === "good" ||
raw.exportQuality === "4k" ||
raw.exportQuality === "source"
? (raw.exportQuality as ExportQuality)
: DEFAULT_PREFS.exportQuality,
Expand Down
66 changes: 66 additions & 0 deletions test-board.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
version: 1
project:
name: "openscreen"
test_command: "npm run test"
repo: "nanameru/openscreen"

source_roots:
- src
- electron
- tests

cases:
- id: TC-007
title: "4K品質は縦横比を維持して短辺2160で出力する"
feature: "4K MP4エクスポート"
scenario: "横長・縦長プロジェクトの4K寸法計算"
status: done
priority: high
type: regression
source: [src/lib/exporter/mp4ExportSettings.ts]
test_file: "src/lib/exporter/mp4ExportSettings.test.ts"
issues: [6]

- id: TC-008
title: "エクスポート画面から4Kを選択して3840×2160で出力する"
feature: "4K品質ボタン"
scenario: "1080p素材を4KへアップスケールしてMP4出力"
status: done
priority: high
type: integration
source: [src/components/ai-edition/ExportDialog.tsx]
test_file: "src/components/ai-edition/ExportDialog.showInFolder.test.tsx"
issues: [6]

- id: TC-009
title: "CLIでquality 4kを指定できる"
feature: "4K CLIエクスポート"
scenario: "openscreen export --quality 4k"
status: done
priority: medium
type: regression
source: [electron/cli/args.ts]
test_file: "electron/cli/args.test.ts"
issues: [6]

- id: TC-010
title: "ユーザー設定の4K品質を読込時に維持する"
feature: "4K品質のユーザー設定"
scenario: "ユーザー設定の保存と読込"
status: done
priority: medium
type: regression
source: [src/lib/userPreferences.ts]
test_file: "src/lib/userPreferences.test.ts"
issues: [6]

- id: TC-011
title: "プロジェクト設定の4K品質を正規化時に維持する"
feature: "4K品質のプロジェクト設定"
scenario: "保存済みプロジェクト設定の読込"
status: done
priority: medium
type: regression
source: [src/components/video-editor/projectPersistence.ts]
test_file: "src/components/video-editor/projectPersistence.test.ts"
issues: [6]