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
19 changes: 4 additions & 15 deletions src/components/ai-edition/Bottombar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { suggestZoomRegions } from "@/lib/ai-edition/store/zoomSuggestions";
import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import { ASPECT_RATIO_PRESETS, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { EditClipModal } from "./Modals";
import styles from "./NewEditorShell.module.css";
import { type Span } from "./RegionTimeline";
Expand Down Expand Up @@ -75,17 +75,6 @@ interface BottombarProps {
timelineVariant?: "edit" | "media";
}

const RATIO_LABELS: Record<AspectRatio, string> = {
"16:9": "16:9",
"9:16": "9:16",
"1:1": "1:1",
"4:3": "4:3",
"4:5": "4:5",
"16:10": "16:10",
"10:16": "10:16",
native: "Original",
};

export function Bottombar({
clips,
videoSources,
Expand Down Expand Up @@ -342,7 +331,7 @@ export function Bottombar({
aria-haspopup="menu"
aria-expanded={ratioOpen}
>
<span>{RATIO_LABELS[settings.aspectRatio]}</span>
<span>{getAspectRatioLabel(settings.aspectRatio)}</span>
<ChevronDown size={10} className="caret" />
</button>
{ratioOpen && ratioMenuRect
Expand All @@ -362,7 +351,7 @@ export function Bottombar({
zIndex: 1000,
}}
>
{ASPECT_RATIOS.map((r) => (
{ASPECT_RATIO_PRESETS.map((r) => (
<button
type="button"
key={r}
Expand All @@ -374,7 +363,7 @@ export function Bottombar({
setRatioOpen(false);
}}
>
{RATIO_LABELS[r]}
{getAspectRatioLabel(r)}
</button>
))}
</div>,
Expand Down
3 changes: 2 additions & 1 deletion src/components/ai-edition/ExportDialog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { collectUsedAssetDims, pickExtremeDims } from "@/lib/ai-edition/document/outputFormat";
import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { collectEffectiveClipDims, collectUsedAssetDims, pickExtremeDims } from "./ExportDialog";
import { collectEffectiveClipDims } from "./ExportDialog";

function asset(p: Partial<AxcutAsset> & Pick<AxcutAsset, "id">): AxcutAsset {
return {
Expand Down
73 changes: 14 additions & 59 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useScopedT } from "@/contexts/I18nContext";
import {
collectUsedAssetDims,
type Dims,
pickExtremeDims,
resolveAspectRatioValue,
} from "@/lib/ai-edition/document/outputFormat";
import {
type DocumentExportOptions,
type ExportVideoCodec,
exportAxcutDocument,
} from "@/lib/ai-edition/exporter/documentExporter";
import type { AxcutDocument } from "@/lib/ai-edition/schema";
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration";
import { probeVideoDimensions } from "@/lib/ai-edition/timeline/duration";
import {
Expand All @@ -38,11 +45,6 @@ import { exportMultiNative, exportNative } from "@/native";
import { nativeBridgeClient } from "@/native/client";
import type { CompositorClipInput } from "@/native/contracts";
import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription";
import {
type AspectRatio,
getAspectRatioValue,
getNativeAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { ModalShell } from "./Modals";
import styles from "./NewEditorShell.module.css";

Expand Down Expand Up @@ -111,45 +113,6 @@ const QUALITY_OPTIONS: Array<{
{ value: "source", labelKey: "exportQuality.high" },
];

type Dims = { width: number; height: number };

/** Single "pick the largest/smallest by pixel count" reducer, shared by every size
* comparison below instead of each one hand-rolling its own reduce + fallback.
* Exported for unit testing only — not part of the component's public surface. */
export function pickExtremeDims(items: Dims[], direction: "largest" | "smallest"): Dims | null {
let best: Dims | null = null;
for (const d of items) {
if (d.width <= 0 || d.height <= 0) continue;
if (!best) {
best = d;
continue;
}
const area = d.width * d.height;
const bestArea = best.width * best.height;
if (direction === "largest" ? area > bestArea : area < bestArea) best = d;
}
return best;
}

/** Raw (uncropped) probed dims for every asset the timeline actually uses — falls back to
* ANY asset with known dims if none of the used ones have probed yet (still loading), so the
* dialog shows *something* rather than blank tiers. Two call sites used to hand-roll this same
* fallback independently; centralized here as the one place it's implemented.
* Exported for unit testing only — not part of the component's public surface. */
export function collectUsedAssetDims(
document: AxcutDocument,
probedAssetDims: Record<string, Dims>,
): Dims[] {
const usedAssetIds = new Set(document.timeline.clips.map((c) => c.assetId));
const dimsOf = (a: AxcutDocument["assets"][number]): Dims => ({
width: a.video?.width || probedAssetDims[a.id]?.width || 0,
height: a.video?.height || probedAssetDims[a.id]?.height || 0,
});
const used = document.assets.filter((a) => usedAssetIds.has(a.id)).map(dimsOf);
if (used.some((d) => d.width > 0 && d.height > 0)) return used;
return document.assets.map(dimsOf);
}

/** Per-CLIP effective (post-crop) pixel dims — crop is stored per-clip (`clip.cropRegion`), not
* per-asset, since the same recording can be framed differently across clips, so this is the
* true footprint each clip contributes to the timeline. Falls back to `collectUsedAssetDims`'s
Expand Down Expand Up @@ -271,24 +234,16 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
? Math.min(smallestSource.width, smallestSource.height)
: null;

// Largest clip's RAW (uncropped) asset dims — deliberately separate from the crop-aware
// `referenceSource` above: this only feeds the "native" output-ASPECT-RATIO option (the
// scene's overall output shape), a different concern from a clip's own cropped pixel size,
// and changing its long-standing (uncropped) meaning isn't part of this fix.
const rawReferenceSource = useMemo(
// Aspect the export normalizes to: the timeline's selected ratio (mirrors documentExporter),
// so the sizes shown match what the export produces. Read through `getEditorSettings` — the
// same typed façade the ratio dropdown writes through and `buildSceneDescription` reads — so
// this dialog can't drift from the compositor if the storage ever moves. `resolveAspectRatioValue`
// owns the legacy "native" case (uncropped reference asset), previously hand-rolled here.
const EXPORT_ASPECT = useMemo(
() =>
document ? pickExtremeDims(collectUsedAssetDims(document, probedAssetDims), "largest") : null,
resolveAspectRatioValue(document, getEditorSettings(document).aspectRatio, probedAssetDims),
[document, probedAssetDims],
);

// Aspect the export normalizes to: the timeline's selected ratio (mirrors
// documentExporter), so the sizes shown match what the export produces.
const timelineAspect =
(document?.legacyEditor as { aspectRatio?: AspectRatio } | null)?.aspectRatio ?? "16:9";
const EXPORT_ASPECT =
timelineAspect === "native" && rawReferenceSource
? getNativeAspectRatioValue(rawReferenceSource.width, rawReferenceSource.height)
: getAspectRatioValue(timelineAspect);
// Output dimensions the export will produce for a given tier, from the (crop-aware)
// SMALLEST clip on the timeline — see `smallestSource` above for why. Only "Source"
// quality actually uses these as its target size; 720p/1080p target a fixed short side
Expand Down
13 changes: 9 additions & 4 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type ZoomFocus,
} from "@/components/video-editor/types";
import { computeCameraFullscreenProgress } from "@/components/video-editor/videoPlayback/cameraFullscreenUtils";
import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat";
import type {
AxcutAnnotationRegion,
AxcutClip,
Expand All @@ -50,7 +51,6 @@ import {
} from "@/lib/compositeLayout";
import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { AnnotationLayer } from "./AnnotationLayer";
import { NativeCompositorOverlay } from "./NativeCompositorOverlay";
import styles from "./NewEditorShell.module.css";
Expand Down Expand Up @@ -103,7 +103,8 @@ const WEBCAM_SOURCE_SIZE = { width: 960, height: 720 };

export function PreviewCanvas(props: PreviewCanvasProps) {
const { settings, setLive, commit } = useEditorSettings();
const assets = useProjectStore((s) => s.document?.assets ?? []);
const document = useProjectStore((s) => s.document);
const assets = document?.assets ?? [];
const frameRef = useRef<HTMLDivElement | null>(null);
const webcamSlotRef = useRef<HTMLDivElement | null>(null);
// One clock per mounted canvas, shared between the screen preview (writer)
Expand Down Expand Up @@ -179,8 +180,12 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// exactly what the frame is styled to (`width: frameSize.width` a few lines below), so it's
// used directly everywhere `canvasSize` used to be — one source of truth, no separate
// observer that can desync from it.
// `resolveAspectRatioValue`, not bare `getAspectRatioValue`: the latter has no document to
// resolve the legacy "native" selection against and answers 16/9 for it, so a project saved
// with "native" over portrait footage framed the preview 16:9 while `pickOutputDims` handed
// the compositor a portrait `output` — preview and export disagreed on the frame's shape.
const frameSize = useMemo(() => {
const ratio = getAspectRatioValue(settings.aspectRatio);
const ratio = resolveAspectRatioValue(document, settings.aspectRatio);
const { width: containerWidth, height: containerHeight } = containerSize;
if (containerWidth <= 0 || containerHeight <= 0)
return { width: containerWidth, height: containerHeight };
Expand All @@ -190,7 +195,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
}
const width = containerWidth;
return { width: Math.round(width), height: Math.round(width / ratio) };
}, [containerSize, settings.aspectRatio]);
}, [containerSize, settings.aspectRatio, document]);

// Crop is per-clip (see clipSchema.cropRegion) — resolve it from whichever
// clip the playhead is currently inside, the same lookup VirtualPreview
Expand Down
Loading
Loading