Skip to content
Open
115 changes: 115 additions & 0 deletions electron/ipc/register/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,121 @@ function isTempPathSafe(tempPath: string): boolean {
return candidate.startsWith(withSep);
}

type CaptionSidecarCue = {
startMs: number;
endMs: number;
text: string;
};

type CaptionSidecarPayload = {
format: "srt" | "vtt" | "both";
cues: CaptionSidecarCue[];
};

function toSrtTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
}

function toVttTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}

function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
if (!Array.isArray(cues)) {
return [];
}

return cues
.filter((cue): cue is CaptionSidecarCue => {
return (
typeof cue === "object" &&
cue !== null &&
typeof cue.startMs === "number" &&
typeof cue.endMs === "number" &&
typeof cue.text === "string" &&
Number.isFinite(cue.startMs) &&
Number.isFinite(cue.endMs) &&
cue.endMs > cue.startMs &&
cue.text.trim().length > 0
);
})
.map((cue) => ({
startMs: cue.startMs,
endMs: cue.endMs,
text: cue.text.replace(/\r\n/g, "\n").trim(),
}));
}

function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
if (typeof payload !== "object" || payload === null) {
return null;
}

const candidate = payload as {
format?: unknown;
cues?: unknown;
};

const format =
candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
? candidate.format
: null;
if (!format) {
return null;
}

const cues = normalizeCaptionSidecarCues(candidate.cues);
if (cues.length === 0) {
return null;
}

return { format, cues };
}

function serializeSrt(cues: CaptionSidecarCue[]): string {
return cues
.map((cue, index) => {
return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
}

function serializeVtt(cues: CaptionSidecarCue[]): string {
const body = cues
.map((cue) => {
return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
return `WEBVTT\n\n${body}`;
}

async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) {
if (!payload) {
return;
}

const parsed = path.parse(videoPath);
const basePath = path.join(parsed.dir, parsed.name);

if (payload.format === "srt" || payload.format === "both") {
await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
}

if (payload.format === "vtt" || payload.format === "both") {
await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
}
}

export function registerExportHandlers() {
ipcMain.handle(
"native-video-export-start",
Expand Down
48 changes: 36 additions & 12 deletions electron/ipc/register/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ function normalizeProjectSaveName(projectName?: string | null) {
return sanitizedName || null;
}

type NamedProjectSaveMode = "rename" | "copy";

function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode {
return value === "copy" ? "copy" : "rename";
}

/**
* Extracts the persisted source video path from a saved project payload.
*/
Expand Down Expand Up @@ -292,8 +298,10 @@ export function registerProjectHandlers() {
try {
const projectsDir = await getProjectsDir()
const preparedProject = ensureProjectDataHasProjectId(projectData)
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
const trustedExistingProjectPath = existingProjectPath &&
path.extname(existingProjectPath).toLowerCase() === `.${PROJECT_FILE_EXTENSION}` &&
(isTrustedProjectPath(existingProjectPath) || isPathInsideDirectory(existingProjectPath, projectsDir))
? path.resolve(existingProjectPath)
: null

if (trustedExistingProjectPath) {
Expand All @@ -309,6 +317,13 @@ export function registerProjectHandlers() {
}
}

if (existingProjectPath) {
return {
success: false,
message: 'Project path is no longer trusted. Use Save As to choose a project file.',
}
}

const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`
const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`

Expand Down Expand Up @@ -351,7 +366,7 @@ export function registerProjectHandlers() {
}
})

ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => {
ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null, mode?: unknown) => {
try {
const normalizedProjectName = normalizeProjectSaveName(projectName)
if (!normalizedProjectName) {
Expand All @@ -362,14 +377,30 @@ export function registerProjectHandlers() {
}

const projectsDir = await getProjectsDir()
const preparedProject = ensureProjectDataHasProjectId(projectData)
const namedSaveMode = normalizeNamedProjectSaveMode(mode)
const activeProjectPath = isTrustedProjectPath(currentProjectPath)
? currentProjectPath
: null
const targetProjectPath = path.join(
projectsDir,
`${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`,
)
const [activeResolvedPath, targetResolvedPath] = await Promise.all([
activeProjectPath ? resolveComparablePath(activeProjectPath) : Promise.resolve(null),
resolveComparablePath(targetProjectPath),
])
const isSavingToDifferentPath =
!activeResolvedPath || activeResolvedPath !== targetResolvedPath
const preparedProject =
namedSaveMode === "copy" && isSavingToDifferentPath
? (() => {
const projectId = randomUUID()
return {
projectId,
projectData: withProjectId(projectData, projectId),
}
})()
: ensureProjectDataHasProjectId(projectData)

const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
targetProjectPath,
Expand All @@ -384,13 +415,7 @@ export function registerProjectHandlers() {
await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl)
await rememberRecentProject(targetProjectPath)

if (activeProjectPath) {
const [activeResolvedPath, targetResolvedPath] = await Promise.all([
resolveComparablePath(activeProjectPath),
resolveComparablePath(targetProjectPath),
])

if (activeResolvedPath !== targetResolvedPath) {
if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) {
await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => {
if (unlinkError.code !== 'ENOENT') {
throw unlinkError
Expand All @@ -407,7 +432,6 @@ export function registerProjectHandlers() {
}
}
await saveRecentProjectPaths(filteredRecentProjectPaths)
}
}

setCurrentProjectPath(targetProjectPath)
Expand Down
67 changes: 46 additions & 21 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type {
ZoomTransitionEasing,
} from "./types";
import {
ADVANCED_VERTICAL_PADDING_MAX,
DEFAULT_AUTO_CAPTION_SETTINGS,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
Expand Down Expand Up @@ -107,6 +108,7 @@ import {
} from "./videoPlayback/uploadedCursorAssets";
import { WebcamCropControl } from "./WebcamCropControl";
import {
getCropMatchedWebcamHeightPercent,
getWebcamPositionForPreset,
normalizeWebcamCropRegion,
resolveWebcamCorner,
Expand Down Expand Up @@ -1662,6 +1664,8 @@ export function SettingsPanel({
const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET;
const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X;
const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y;
const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion);

const getWallpaperTileState = (candidateValue: string, previewPath?: string) => {
Expand Down Expand Up @@ -2413,7 +2417,7 @@ export function SettingsPanel({
value={padding.top}
defaultValue={DEFAULT_PADDING.top}
min={0}
max={100}
max={ADVANCED_VERTICAL_PADDING_MAX}
step={1}
onChange={(v) => handlePaddingSideChange("top", v)}
formatValue={(v) => `${v}%`}
Expand All @@ -2424,7 +2428,7 @@ export function SettingsPanel({
value={padding.bottom}
defaultValue={DEFAULT_PADDING.bottom}
min={0}
max={100}
max={ADVANCED_VERTICAL_PADDING_MAX}
step={1}
onChange={(v) => handlePaddingSideChange("bottom", v)}
formatValue={(v) => `${v}%`}
Expand Down Expand Up @@ -3322,22 +3326,19 @@ export function SettingsPanel({
)}
</div>
)}
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{showDevMotionControls
? tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)
: tSettings(
"effects.exportBlurLocked",
"Export blur is fixed for this build.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
{showDevMotionControls ? (
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
</div>
</div>
</div>
) : null}
{selectedZoomId && (
<Button
onClick={() => {
Expand Down Expand Up @@ -3874,13 +3875,24 @@ export function SettingsPanel({
/>
</div>
<SliderControl
label={tSettings("effects.webcamSize")}
value={webcam?.size ?? DEFAULT_WEBCAM_SIZE}
label={tSettings("effects.webcamWidth", "Webcam Width")}
value={webcamWidth}
defaultValue={DEFAULT_WEBCAM_SIZE}
min={10}
max={100}
step={1}
onChange={(v) => updateWebcam({ size: v })}
onChange={(v) => updateWebcam({ width: v, size: v })}
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<SliderControl
label={tSettings("effects.webcamHeight", "Webcam Height")}
value={webcamHeight}
defaultValue={DEFAULT_WEBCAM_SIZE}
min={10}
max={100}
step={1}
onChange={(v) => updateWebcam({ height: v })}
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
Expand All @@ -3906,7 +3918,20 @@ export function SettingsPanel({
previewCurrentTime={webcamPreviewCurrentTime}
previewPlaying={webcamPreviewPlaying}
previewTimeOffsetMs={webcam?.timeOffsetMs}
onCropChange={(cropRegion) => updateWebcam({ cropRegion })}
onCropChange={(cropRegion, previewFrame) =>
updateWebcam({
cropRegion,
height: previewFrame
? getCropMatchedWebcamHeightPercent(
webcamWidth,
webcamWidth,
Comment on lines +3925 to +3927

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve user-set webcam height when applying crop updates.

Line 3927 passes webcamWidth as both width and height, which forces auto-height recomputation on every crop edit and overwrites manual height choices. Pass the current webcamHeight as the second argument.

Proposed fix
 											height: previewFrame
 												? getCropMatchedWebcamHeightPercent(
 														webcamWidth,
-														webcamWidth,
+														webcamHeight,
 														previewFrame.width,
 														previewFrame.height,
 														cropRegion,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
? getCropMatchedWebcamHeightPercent(
webcamWidth,
webcamWidth,
height: previewFrame
? getCropMatchedWebcamHeightPercent(
webcamWidth,
webcamHeight,
previewFrame.width,
previewFrame.height,
cropRegion,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/SettingsPanel.tsx` around lines 3925 - 3927, In
the getCropMatchedWebcamHeightPercent function call, the second argument is
incorrectly passing webcamWidth when it should pass webcamHeight. This causes
the height to be recalculated based on width every time, overwriting user manual
height adjustments. Replace the second webcamWidth argument with webcamHeight to
preserve the user's previously set height value when applying crop updates.

previewFrame.width,
previewFrame.height,
cropRegion,
)
: webcamHeight,
})
}
/>
</div>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
Expand Down
Loading