Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ <h2 class="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">R
id="cameraSource"
class="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-neutral-600 transition-colors"
></select>
<label class="mt-1.5 flex items-center gap-2 text-xs text-neutral-400 cursor-pointer">
<input
type="checkbox"
id="cameraMirrorCheckbox"
class="accent-neutral-400"
checked
/>
<span>Mirror camera</span>
</label>
</div>
<div>
<label class="block text-xs font-medium text-neutral-500 uppercase tracking-wider mb-1"
Expand Down
18 changes: 13 additions & 5 deletions src/main/services/premiere-export-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export interface PremiereExportOptions {
sourceWidth: number;
sourceHeight: number;
cameraSyncOffsetMs: number;
// Defaults to true to preserve historical selfie-mirror behavior when the
// caller has not yet plumbed the setting through.
cameraMirror?: boolean;
takes: PremiereExportTakeInput[];
sections: PremiereExportSectionInput[];
keyframes: Keyframe[];
Expand Down Expand Up @@ -139,10 +142,13 @@ function buildCameraTranscodeArgs(
inputPath: string,
outputPath: string,
cameraSyncOffsetMs: number,
includeAudio: boolean
includeAudio: boolean,
mirror: boolean
): string[] {
// Mirror horizontally only; preserve native dimensions so the user can
// expand / re-crop the full camera frame in Premiere.
// Preserve native dimensions so the user can expand / re-crop the full
// camera frame in Premiere. Horizontal mirroring is optional so projects
// that disable selfie-mirror do not get a flipped asset shipped to the
// NLE.
const offsetSec = normalizeCameraSyncOffsetMs(cameraSyncOffsetMs) / 1000;
const args = [
'-progress',
Expand All @@ -154,7 +160,8 @@ function buildCameraTranscodeArgs(
if (Math.abs(offsetSec) > 0.0005) {
args.push('-itsoffset', (-offsetSec).toFixed(3));
}
args.push('-i', inputPath, '-map', '0:v:0?', '-vf', 'hflip,setsar=1');
const videoFilter = mirror ? 'hflip,setsar=1' : 'setsar=1';
args.push('-i', inputPath, '-map', '0:v:0?', '-vf', videoFilter);
if (includeAudio) {
// Camera file owns the mic for this take; preserve it so Premiere can
// import the PiP clip with its built-in audio track.
Expand Down Expand Up @@ -351,7 +358,8 @@ export async function exportPremiereProject(
job.inputPath,
job.outputPath,
opts.cameraSyncOffsetMs,
job.includeCameraAudio === true
job.includeCameraAudio === true,
opts.cameraMirror !== false
);
} else {
args = buildAudioTranscodeArgs(job.inputPath, job.outputPath);
Expand Down
9 changes: 8 additions & 1 deletion src/main/services/preview-render-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface ComputeTimelineHashInput {
cameraSyncOffsetMs: number;
sourceWidth: number;
sourceHeight: number;
cameraMirror?: boolean;
}

function round(value: unknown, digits = 3): number {
Expand Down Expand Up @@ -130,7 +131,11 @@ export function computeTimelineHash(input: ComputeTimelineHashInput): string {
fit: input?.screenFitMode === 'fit' ? 'fit' : 'fill',
camSync: Math.round(Number(input?.cameraSyncOffsetMs) || 0),
w: Math.round(Number(input?.sourceWidth) || 0),
h: Math.round(Number(input?.sourceHeight) || 0)
h: Math.round(Number(input?.sourceHeight) || 0),
// Including the mirror flag in the hash ensures that toggling the
// camera orientation invalidates any cached preview so the next render
// actually reflects the new setting.
camMirror: input?.cameraMirror !== false
};

return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
Expand Down Expand Up @@ -159,6 +164,7 @@ export interface GeneratePreviewOpts {
cameraSyncOffsetMs: number;
sourceWidth: number;
sourceHeight: number;
cameraMirror?: boolean;
}

export interface GeneratePreviewResult {
Expand Down Expand Up @@ -213,6 +219,7 @@ export async function generatePreview(
exportAudioPreset,
exportVideoPreset,
cameraSyncOffsetMs: opts.cameraSyncOffsetMs,
cameraMirror: opts.cameraMirror,
sourceWidth: opts.sourceWidth,
sourceHeight: opts.sourceHeight,
outputPath: previewPath
Expand Down
14 changes: 10 additions & 4 deletions src/main/services/render-filter-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ export function buildFilterComplex(
sourceHeight: number,
canvasW: number,
canvasH: number,
targetFps = 30
targetFps = 30,
cameraMirror = true
): string {
const { outW, outH } = resolveOutputSize(canvasW, canvasH);
const scaleX = outW / AUTHORING_CANVAS_W;
Expand Down Expand Up @@ -282,6 +283,11 @@ export function buildFilterComplex(
const hasPip = keyframes.some((keyframe) => keyframe.pipVisible);
const hasCamFull = keyframes.some((keyframe) => keyframe.cameraFullscreen);

// Only emit hflip when the project wants the camera mirrored. Dropping the
// filter entirely (vs. letting it be a no-op) keeps the filter graph a hair
// simpler and makes the intent clear when inspecting the ffmpeg command.
const mirrorChain = cameraMirror ? ',hflip' : '';

if (hasPip && hasCamFull) {
const alphaExpr = buildAlphaExpr(keyframes);
const roundCornerExpr = `lte(pow(max(0,max(${radius}-X,X-${maxCoord})),2)+pow(max(0,max(${radius}-Y,Y-${maxCoord})),2),${radiusSquared})`;
Expand All @@ -293,18 +299,18 @@ export function buildFilterComplex(
const xExpr = buildPosExpr(scaledKeyframes, 'pipX');
const yExpr = buildPosExpr(scaledKeyframes, 'pipY');

return `${screenFilter};[1:v]setpts=PTS-STARTPTS,hflip,split[cam1][cam2];${camPipFilter};${camFullFilter};[screen][cam]overlay=x='${xExpr}':y='${yExpr}':format=auto[with_pip];[with_pip][camfull]overlay=0:0:format=auto[out]`;
return `${screenFilter};[1:v]setpts=PTS-STARTPTS${mirrorChain},split[cam1][cam2];${camPipFilter};${camFullFilter};[screen][cam]overlay=x='${xExpr}':y='${yExpr}':format=auto[with_pip];[with_pip][camfull]overlay=0:0:format=auto[out]`;
}

if (hasCamFull) {
const camFullAlpha = buildCamFullAlphaExpr(keyframes);
const camFullFilter = `[1:v]setpts=PTS-STARTPTS,hflip,scale=${outW}:${outH}:flags=lanczos:force_original_aspect_ratio=increase,crop=${outW}:${outH},format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='255*(${camFullAlpha})'[camfull]`;
const camFullFilter = `[1:v]setpts=PTS-STARTPTS${mirrorChain},scale=${outW}:${outH}:flags=lanczos:force_original_aspect_ratio=increase,crop=${outW}:${outH},format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='255*(${camFullAlpha})'[camfull]`;
return `${screenFilter};${camFullFilter};[screen][camfull]overlay=0:0:format=auto[out]`;
}

const alphaExpr = buildAlphaExpr(keyframes);
const roundCornerExpr = `lte(pow(max(0,max(${radius}-X,X-${maxCoord})),2)+pow(max(0,max(${radius}-Y,Y-${maxCoord})),2),${radiusSquared})`;
const camFilter = `[1:v]setpts=PTS-STARTPTS,hflip,crop='min(iw,ih)':'min(iw,ih)':'(iw-min(iw,ih))/2':'(ih-min(iw,ih))/2',scale=${actualPipSize}:${actualPipSize},format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='255*${roundCornerExpr}*(${alphaExpr})'[cam]`;
const camFilter = `[1:v]setpts=PTS-STARTPTS${mirrorChain},crop='min(iw,ih)':'min(iw,ih)':'(iw-min(iw,ih))/2':'(ih-min(iw,ih))/2',scale=${actualPipSize}:${actualPipSize},format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='255*${roundCornerExpr}*(${alphaExpr})'[cam]`;

const xExpr = buildPosExpr(scaledKeyframes, 'pipX');
const yExpr = buildPosExpr(scaledKeyframes, 'pipY');
Expand Down
8 changes: 7 additions & 1 deletion src/main/services/render-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export interface RenderCompositeOptions {
exportAudioPreset?: ExportAudioPreset;
exportVideoPreset?: ExportVideoPreset;
cameraSyncOffsetMs?: number;
// Whether the camera should be mirrored horizontally in the composite.
// Defaults to true to preserve historical behavior for legacy callers that
// do not plumb this option through.
cameraMirror?: boolean;
sourceWidth?: number;
sourceHeight?: number;
outputFolder?: string;
Expand Down Expand Up @@ -574,6 +578,7 @@ export async function renderComposite(
const keyframes = Array.isArray(opts.keyframes) ? opts.keyframes : [];
const pipSize = Number.isFinite(Number(opts.pipSize)) ? Number(opts.pipSize) : 422;
const screenFitMode = opts.screenFitMode === 'fit' ? 'fit' : 'fill';
const cameraMirror = opts.cameraMirror !== false;
const exportAudioPreset = normalizeExportAudioPreset(opts.exportAudioPreset);
const exportVideoPreset = normalizeExportVideoPreset(opts.exportVideoPreset);
const cameraSyncOffsetMs = normalizeCameraSyncOffsetMs(opts.cameraSyncOffsetMs);
Expand Down Expand Up @@ -813,7 +818,8 @@ export async function renderComposite(
sourceHeight,
canvasW,
canvasH,
targetFps
targetFps,
cameraMirror
);
assertOverlayFilterSize(overlayFilter);
const adaptedOverlay = overlayFilter
Expand Down
Loading
Loading