chore: normalize line endings

This commit is contained in:
Maze Winther 2026-03-27 00:55:15 +01:00
parent 82817cb50c
commit 4d127a038b
736 changed files with 73880 additions and 73782 deletions

Binary file not shown.

View File

@ -9,7 +9,6 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
import { GuideOverlay } from "./guide-overlay";
@ -134,12 +133,7 @@ function PreviewCanvas({
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
const lastFrameTime = getLastFrameTime({
duration: renderTree.duration,
fps: renderer.fps,
});
const renderTime = Math.min(time, lastFrameTime);
const renderTime = editor.playback.getCurrentTime();
const frame = Math.floor(renderTime * renderer.fps);
if (

View File

@ -30,6 +30,9 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
this.editor.project.ratchetFpsForImportedMedia({
importedAssets: [newAsset],
});
return newAsset;
} catch (error) {
console.error("Failed to save media asset:", error);

View File

@ -13,19 +13,19 @@ export class PlaybackManager {
constructor(private editor: EditorCore) {
this.editor.timeline.subscribe(() => {
const duration = this.editor.timeline.getTotalDuration();
if (this.currentTime > duration && duration > 0) {
this.currentTime = duration;
const maxTime = this.editor.timeline.getLastSeekableTime();
if (this.currentTime > maxTime && maxTime > 0) {
this.currentTime = maxTime;
this.notify();
}
});
}
play(): void {
const duration = this.editor.timeline.getTotalDuration();
const maxTime = this.editor.timeline.getLastSeekableTime();
if (duration > 0) {
if (this.currentTime >= duration) {
if (maxTime > 0) {
if (this.currentTime >= maxTime) {
this.seek({ time: 0 });
}
}
@ -50,8 +50,8 @@ export class PlaybackManager {
}
seek({ time }: { time: number }): void {
const duration = this.editor.timeline.getTotalDuration();
this.currentTime = Math.max(0, Math.min(duration, time));
const maxTime = this.editor.timeline.getLastSeekableTime();
this.currentTime = Math.max(0, Math.min(maxTime, time));
this.notify();
window.dispatchEvent(
@ -154,16 +154,16 @@ export class PlaybackManager {
this.lastUpdate = now;
const newTime = this.currentTime + delta;
const duration = this.editor.timeline.getTotalDuration();
const maxTime = this.editor.timeline.getLastSeekableTime();
if (duration > 0 && newTime >= duration) {
if (maxTime > 0 && newTime >= maxTime) {
this.pause();
this.currentTime = duration;
this.currentTime = maxTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: duration },
detail: { time: maxTime },
}),
);
} else {

View File

@ -29,6 +29,8 @@ import {
import { loadFonts } from "@/lib/fonts/google-fonts";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
import type { MediaAsset } from "@/lib/media/types";
export interface MigrationState {
isMigrating: boolean;
@ -492,6 +494,23 @@ export class ProjectManager {
command.execute();
}
ratchetFpsForImportedMedia({
importedAssets,
}: {
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
}): number | null {
if (!this.active) return null;
const nextFps = getRaisedProjectFpsForImportedMedia({
currentFps: this.active.settings.fps,
importedAssets,
});
if (nextFps === null) return null;
new UpdateProjectSettingsCommand({ fps: nextFps }).execute();
return nextFps;
}
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
if (!this.active) return;

View File

@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { formatTimeCode } from "@/lib/time";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@ -81,9 +81,7 @@ export class RendererManager {
}
const { canvasSize, fps } = activeProject.settings;
const currentTime = this.editor.playback.getCurrentTime();
const lastFrameTime = getLastFrameTime({ duration, fps });
const renderTime = Math.min(currentTime, lastFrameTime);
const renderTime = this.editor.playback.getCurrentTime();
const renderer = new CanvasRenderer({
width: canvasSize.width,

View File

@ -13,6 +13,7 @@ import type {
AnimationValue,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time";
import {
AddTrackCommand,
RemoveTrackCommand,
@ -224,6 +225,13 @@ export class TimelineManager {
return calculateTotalDuration({ tracks: this.getTracks() });
}
getLastSeekableTime(): number {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return getLastFrameTime({ duration, fps });
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.getTracks().find((track) => track.id === trackId) ?? null;
}

View File

@ -5,11 +5,15 @@ import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { getHighestImportedVideoFps } from "@/lib/project/fps";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
private previousProjectFps: number | null = null;
private appliedProjectFps: number | null = null;
constructor(
private projectId: string,
@ -31,6 +35,10 @@ export class AddMediaAssetCommand extends Command {
editor.media.setAssets({
assets: [...this.savedAssets, this.createdAsset],
});
this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null;
this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({
importedAssets: [this.createdAsset],
});
storageService
.saveMediaAsset({
@ -64,6 +72,8 @@ export class AddMediaAssetCommand extends Command {
editor.timeline.deleteElements({ elements: orphanedElements });
}
this.restoreProjectFpsAfterFailedSave({ editor });
if (storageService.isQuotaExceededError({ error })) {
toast.error("Not enough browser storage", {
description: error instanceof Error ? error.message : undefined,
@ -90,4 +100,28 @@ export class AddMediaAssetCommand extends Command {
getAssetId(): string {
return this.assetId;
}
private restoreProjectFpsAfterFailedSave({
editor,
}: {
editor: EditorCore;
}): void {
if (this.previousProjectFps === null || this.appliedProjectFps === null) return;
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
if (activeProject.settings.fps !== this.appliedProjectFps) return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
});
if (
highestRemainingVideoFps !== null &&
highestRemainingVideoFps >= this.appliedProjectFps
) {
return;
}
new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute();
}
}

View File

@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import {
getHighestImportedVideoFps,
getRaisedProjectFpsForImportedMedia,
} from "@/lib/project/fps";
describe("getHighestImportedVideoFps", () => {
test("returns the highest valid video fps", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "audio" },
{ type: "video", fps: 30 },
{ type: "image", fps: 120 },
{ type: "video", fps: 60 },
],
}),
).toBe(60);
});
test("ignores missing and invalid fps values", () => {
expect(
getHighestImportedVideoFps({
mediaAssets: [
{ type: "video" },
{ type: "video", fps: 0 },
{ type: "video", fps: -10 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});
describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [{ type: "video", fps: 60 }],
}),
).toBe(60);
});
test("does not lower the project fps for lower-fps imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 60,
importedAssets: [{ type: "video", fps: 10 }],
}),
).toBeNull();
});
test("ignores non-video imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
importedAssets: [
{ type: "image", fps: 60 },
{ type: "audio", fps: 120 },
],
}),
).toBeNull();
});
});

View File

@ -0,0 +1,39 @@
import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
export function getHighestImportedVideoFps({
mediaAssets,
}: {
mediaAssets: MediaAssetFpsInput[];
}): number | null {
let highestFps: number | null = null;
for (const asset of mediaAssets) {
const fps = asset.fps ?? Number.NaN;
if (asset.type !== "video") continue;
if (!Number.isFinite(fps) || fps <= 0) continue;
highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
}
return highestFps;
}
export function getRaisedProjectFpsForImportedMedia({
currentFps,
importedAssets,
}: {
currentFps: number;
importedAssets: MediaAssetFpsInput[];
}): number | null {
const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets,
});
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
return null;
}
return highestImportedVideoFps;
}

View File

@ -207,7 +207,8 @@ export function getSnappedSeekTime({
fps: number;
}): number {
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
return Math.max(0, Math.min(duration, snappedTime));
const lastFrame = getLastFrameTime({ duration, fps });
return Math.max(0, Math.min(lastFrame, snappedTime));
}
export function getLastFrameTime({

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "opencut",
@ -11,7 +12,7 @@
"next": "^16.1.3",
},
"devDependencies": {
"turbo": "^2.7.5",
"turbo": "^2.8.20",
"typescript": "5.8.3",
},
},
@ -808,6 +809,18 @@
"@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
@ -1720,19 +1733,7 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.7.5", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.5", "turbo-darwin-arm64": "2.7.5", "turbo-linux-64": "2.7.5", "turbo-linux-arm64": "2.7.5", "turbo-windows-64": "2.7.5", "turbo-windows-arm64": "2.7.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-7Imdmg37joOloTnj+DPrab9hIaQcDdJ5RwSzcauo/wMOSAgO+A/I/8b3hsGGs6PWQz70m/jkPgdqWsfNKtwwDQ=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wCoDHMiTf3FgLAbZHDDx/unNNonSGhsF5AbbYODbxnpYyoKDpEYacUEPjZD895vDhNvYCH0Nnk24YsP4n/cD6g=="],
"turbo-linux-64": ["turbo-linux-64@2.7.5", "", { "os": "linux", "cpu": "x64" }, "sha512-KKPvhOmJMmzWj/yjeO4LywkQ85vOJyhru7AZk/+c4B6OUh/odQ++SiIJBSbTG2lm1CuV5gV5vXZnf/2AMlu3Zg=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-8PIva4L6BQhiPikUTds9lSFSHXVDAsEvV6QUlgwPsXrtXVQMVi6Sv9p+IxtlWQFvGkdYJUgX9GnK2rC030Xcmw=="],
"turbo-windows-64": ["turbo-windows-64@2.7.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rupskv/mkIUgQXzX/wUiK00mKMorQcK8yzhGFha/D5lm05FEnLx8dsip6rWzMcVpvh+4GUMA56PgtnOgpel2AA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw=="],
"turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],

View File

@ -27,7 +27,7 @@
"next": "^16.1.3"
},
"devDependencies": {
"turbo": "^2.7.5",
"turbo": "^2.8.20",
"typescript": "5.8.3"
},
"trustedDependencies": [

View File

@ -1,29 +0,0 @@
import { z } from "zod";
const toolsEnvSchema = z.object({
// Node
NODE_ENV: z.enum(["development", "production", "test"]),
ANALYZE: z.string().optional(),
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
// Public
NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
CLOUDFLARE_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
R2_BUCKET_NAME: z.string(),
});
export type ToolsEnv = z.infer<typeof toolsEnvSchema>;
export const toolsEnv = toolsEnvSchema.parse(process.env);

View File

@ -1 +0,0 @@
/// <reference types="node" />

View File

@ -1,34 +0,0 @@
import { z } from "zod";
const webEnvSchema = z.object({
// Node
NODE_ENV: z.enum(["development", "production", "test"]),
ANALYZE: z.string().optional(),
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
// Public
NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
MARBLE_WORKSPACE_KEY: z.string(),
FREESOUND_CLIENT_ID: z.string(),
FREESOUND_API_KEY: z.string(),
CLOUDFLARE_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
R2_BUCKET_NAME: z.string(),
MODAL_TRANSCRIPTION_URL: z.url(),
});
export type WebEnv = z.infer<typeof webEnvSchema>;
export const webEnv = webEnvSchema.parse(process.env);