mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b305917ac1 |
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"opencut-wasm": "^0.2.10",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
|
||||
describe("animated params", () => {
|
||||
test("snaps and clamps number params", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 0.62,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 1.2,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects NaN and non-number values for number params", () => {
|
||||
const param = {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number" as const,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("passthrough with step <= 0 guard", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "x",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0,
|
||||
},
|
||||
value: 0.123,
|
||||
}),
|
||||
).toBe(0.123);
|
||||
});
|
||||
|
||||
test("accepts valid select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
});
|
||||
|
||||
test("rejects select values outside the allowed options", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
},
|
||||
value: "screen",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-string select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [{ value: "normal", label: "Normal" }],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
test("boolean params accept booleans and reject other types", () => {
|
||||
const param = {
|
||||
key: "visible",
|
||||
label: "Visible",
|
||||
type: "boolean" as const,
|
||||
default: true,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||
});
|
||||
|
||||
test("color params accept strings and reject other types", () => {
|
||||
const param = {
|
||||
key: "fill",
|
||||
label: "Fill",
|
||||
type: "color" as const,
|
||||
default: "#ffffff",
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("number");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("color");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("discrete");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("discrete");
|
||||
});
|
||||
|
||||
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("hold");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("hold");
|
||||
});
|
||||
|
||||
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
}),
|
||||
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
|
||||
|
||||
export function getAnimationParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "number" | "color" | "discrete" {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getAnimationParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "linear" | "hold" {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
export function getAnimationParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
|
||||
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
|
||||
* untrusted inputs (persisted state, paste payloads, programmatic updates).
|
||||
* The caller does not need to pre-validate; null means "this value cannot live
|
||||
* on this param".
|
||||
*/
|
||||
export function coerceAnimationParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: unknown;
|
||||
}): DynamicAnimationPathValue | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
@@ -55,26 +56,23 @@ export function parseEffectParamPath({
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effectId,
|
||||
params,
|
||||
effect,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effectId: string;
|
||||
params: ParamValues;
|
||||
effect: Effect;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
const resolved: ParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: safeLocalTime,
|
||||
localTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { ParamValues } from "@/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/graphics";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
@@ -35,29 +39,33 @@ export function parseGraphicParamPath({
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
params,
|
||||
definitions,
|
||||
animations,
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
definitions: ParamDefinition[];
|
||||
animations?: ElementAnimations;
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const resolved: ParamValues = { ...params };
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
|
||||
for (const param of definitions) {
|
||||
for (const param of definition.params) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!animations?.bindings[path]) {
|
||||
if (!element.animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
animations: element.animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: params[param.key] ?? param.default,
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,31 @@ export {
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
@@ -58,6 +75,15 @@ export {
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
@@ -68,7 +94,3 @@ export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
} from "./path";
|
||||
|
||||
@@ -8,13 +8,13 @@ import type {
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./path";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
channel,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
cloneAnimationBinding,
|
||||
createAnimationBinding,
|
||||
@@ -29,7 +31,10 @@ import {
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
} from "./property-registry";
|
||||
|
||||
function isNearlySameTime({
|
||||
leftTime,
|
||||
@@ -522,6 +527,47 @@ export function upsertPathKeyframe({
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||
import { isEffectParamPath } from "./effect-param-channel";
|
||||
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||
|
||||
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
+15
-50
@@ -3,15 +3,10 @@ import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
ElementAnimations,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { parseColorToLinearRgba } from "@/animation/binding-values";
|
||||
import { isAnimationPropertyPath } from "@/animation/path";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
@@ -22,6 +17,13 @@ import {
|
||||
} from "@/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
kind: AnimationBindingKind;
|
||||
@@ -311,6 +313,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
}),
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
@@ -341,7 +349,6 @@ export function getElementBaseValueForProperty({
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
@@ -359,7 +366,6 @@ export function withElementBaseValueForProperty({
|
||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
@@ -382,44 +388,3 @@ export function coerceAnimationValueForProperty({
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.coerceValue({ value });
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
@@ -33,6 +37,107 @@ export function getElementLocalTime({
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
animations,
|
||||
propertyPath,
|
||||
|
||||
+97
-26
@@ -3,32 +3,30 @@ import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||
import { getGraphicDefinition } from "@/graphics";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { isAnimationPropertyPath } from "@/animation/path";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./animation-properties";
|
||||
} from "./property-registry";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
@@ -39,16 +37,79 @@ export interface AnimationPathDescriptor {
|
||||
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||
}
|
||||
|
||||
// Number/discrete bindings expose a single component named "value"
|
||||
// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
|
||||
// numeric ranges yet — revisit when one does.
|
||||
function paramNumericRanges({
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationBindingKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||
const range = getAnimationParamNumericRange({ param });
|
||||
return range ? { value: range } : undefined;
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForParam({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
@@ -71,13 +132,13 @@ function buildGraphicParamDescriptor({
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
@@ -119,13 +180,13 @@ function buildEffectParamDescriptor({
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
@@ -149,6 +210,16 @@ function buildEffectParamDescriptor({
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
@@ -48,13 +50,7 @@ export interface AnimationPropertyValueMap {
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = number | string | boolean;
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = ParamValues[string];
|
||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||
TPath extends AnimationPropertyPath
|
||||
? AnimationPropertyValueMap[TPath]
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "./types";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
@@ -11,17 +11,15 @@ import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/timeline/components";
|
||||
import { PreviewPanel } from "@/preview/components";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorNoticeBar } from "@/components/editor/editor-notice-bar";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { usePanelStore } from "@/editor/panel-store";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { usePasteMedia } from "@/media/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo } from "react";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import {
|
||||
createPreviewOverlayControl,
|
||||
@@ -43,7 +41,7 @@ export default function Editor() {
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<DegradedRendererBanner />
|
||||
<EditorNoticeBar />
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
@@ -57,27 +55,6 @@ export default function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
function DegradedRendererBanner() {
|
||||
const isDegraded = useEditor((e) => e.renderer.isDegraded);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
if (!isDegraded || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span>For the best experience, open OpenCut in Chrome.</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="p-0 w-auto [&_svg]:size-3.5"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
@@ -8,7 +9,6 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
function pasteKeyframesIntoElement({
|
||||
|
||||
@@ -2,12 +2,12 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
hasKeyframesForPath,
|
||||
removeElementKeyframe,
|
||||
resolveAnimationTarget,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe } from "@/animation";
|
||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
buildEffectParamPath,
|
||||
resolveAnimationTarget,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type { AnimationInterpolation } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const toneClassName = {
|
||||
info: "bg-accent text-muted-foreground",
|
||||
warning: "bg-caution/10 text-caution",
|
||||
error: "bg-destructive/10 text-destructive",
|
||||
} as const;
|
||||
|
||||
export function EditorNoticeBar() {
|
||||
const editor = useEditor();
|
||||
const notices = useEditor((e) => e.notices.getNotices());
|
||||
|
||||
if (notices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{notices.map((notice) => (
|
||||
<div
|
||||
key={notice.id}
|
||||
className={cn(
|
||||
"border-b h-9 flex items-center justify-center gap-2 px-3 text-xs",
|
||||
toneClassName[notice.tone],
|
||||
)}
|
||||
>
|
||||
<span>{notice.message}</span>
|
||||
{notice.dismissible ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="p-0 w-auto [&_svg]:size-3.5"
|
||||
onClick={() => editor.notices.dismiss({ id: notice.id })}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+95
-95
@@ -1,95 +1,95 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const onChange = ({ color }: { color: string }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: color,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isKeyframedAtTime,
|
||||
hasAnimatedKeyframes,
|
||||
keyframeIdAtTime,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const onChange = ({ color }: { color: string }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: color,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isKeyframedAtTime,
|
||||
hasAnimatedKeyframes,
|
||||
keyframeIdAtTime,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
|
||||
+174
-174
@@ -1,174 +1,174 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue,
|
||||
parse,
|
||||
valueAtPlayhead,
|
||||
step,
|
||||
buildBaseUpdates,
|
||||
buildAdditionalKeyframes,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
step?: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||
buildAdditionalKeyframes?: ({
|
||||
value,
|
||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const snapValue = (value: number) =>
|
||||
step != null ? snapToStep({ value, step }) : value;
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
const updatedAnimations = [
|
||||
{ propertyPath, value: nextValue },
|
||||
...additionalKeyframes,
|
||||
].reduce(
|
||||
(currentAnimations, keyframe) =>
|
||||
upsertElementKeyframe({
|
||||
animations: currentAnimations,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: { animations: updatedAnimations },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const propertyDraft = usePropertyDraft({
|
||||
displayValue,
|
||||
parse: (input) => {
|
||||
const parsedValue = parse(input);
|
||||
return parsedValue === null ? null : snapValue(parsedValue);
|
||||
},
|
||||
onPreview: (value) => previewValue({ value }),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: snapValue(valueAtPlayhead),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||
...additionalKeyframes.map((keyframe) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
})),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...propertyDraft,
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
toggleKeyframe,
|
||||
commitValue,
|
||||
};
|
||||
}
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue,
|
||||
parse,
|
||||
valueAtPlayhead,
|
||||
step,
|
||||
buildBaseUpdates,
|
||||
buildAdditionalKeyframes,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
step?: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||
buildAdditionalKeyframes?: ({
|
||||
value,
|
||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const snapValue = (value: number) =>
|
||||
step != null ? snapToStep({ value, step }) : value;
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
const updatedAnimations = [
|
||||
{ propertyPath, value: nextValue },
|
||||
...additionalKeyframes,
|
||||
].reduce(
|
||||
(currentAnimations, keyframe) =>
|
||||
upsertElementKeyframe({
|
||||
animations: currentAnimations,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: { animations: updatedAnimations },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const propertyDraft = usePropertyDraft({
|
||||
displayValue,
|
||||
parse: (input) => {
|
||||
const parsedValue = parse(input);
|
||||
return parsedValue === null ? null : snapValue(parsedValue);
|
||||
},
|
||||
onPreview: (value) => previewValue({ value }),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: snapValue(valueAtPlayhead),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitValue = ({ value }: { value: number }) => {
|
||||
const nextValue = snapValue(value);
|
||||
if (shouldUseAnimatedChannel) {
|
||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||
...additionalKeyframes.map((keyframe) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath: keyframe.propertyPath,
|
||||
time: localTime,
|
||||
value: keyframe.value,
|
||||
})),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...propertyDraft,
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
toggleKeyframe,
|
||||
commitValue,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-8
@@ -3,18 +3,16 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
|
||||
@@ -81,12 +79,12 @@ export function useKeyframedParamProperty({
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationParamValue({
|
||||
coerceAnimationValueForParam({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
|
||||
@@ -12,8 +12,37 @@ import { useEditorActions } from "@/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/fonts/google-fonts";
|
||||
import {
|
||||
initializeGpuRenderer,
|
||||
isGpuAvailable,
|
||||
type GpuRendererInitializationResult,
|
||||
} from "@/services/renderer/gpu-renderer";
|
||||
import type { EditorNoticeInput } from "@/core/managers/editor-notices-manager";
|
||||
|
||||
const RENDERER_NOTICES: Record<
|
||||
GpuRendererInitializationResult["kind"],
|
||||
EditorNoticeInput | null
|
||||
> = {
|
||||
ready: null,
|
||||
"software-fallback-adapter": {
|
||||
id: "software-fallback-adapter",
|
||||
tone: "warning",
|
||||
message:
|
||||
"This browser is using a software-only graphics adapter. Preview rendering may fail or appear blank. If that happens, switch to a hardware-accelerated browser.",
|
||||
dismissible: true,
|
||||
},
|
||||
"webgl-fallback": {
|
||||
id: "webgl-fallback",
|
||||
tone: "info",
|
||||
message:
|
||||
"WebGPU is unavailable in this browser, so OpenCut is rendering with a WebGL fallback. Performance and visual fidelity may be reduced.",
|
||||
dismissible: true,
|
||||
},
|
||||
unavailable: {
|
||||
id: "gpu-unavailable",
|
||||
tone: "warning",
|
||||
message:
|
||||
"GPU rendering is unavailable in this browser. OpenCut may run with degraded rendering support. For the best experience, use a hardware-accelerated browser.",
|
||||
dismissible: true,
|
||||
},
|
||||
};
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
@@ -38,8 +67,12 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await initializeGpuRenderer();
|
||||
editor.renderer.setDegraded(!isGpuAvailable());
|
||||
const gpuInitializationResult = await initializeGpuRenderer();
|
||||
const rendererNotice = RENDERER_NOTICES[gpuInitializationResult.kind];
|
||||
editor.notices.setScopeNotices({
|
||||
scope: "renderer",
|
||||
notices: rendererNotice ? [rendererNotice] : [],
|
||||
});
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AudioManager } from "./managers/audio-manager";
|
||||
import { SelectionManager } from "./managers/selection-manager";
|
||||
import { ClipboardManager } from "./managers/clipboard-manager";
|
||||
import { DiagnosticsManager } from "./managers/diagnostics-manager";
|
||||
import { EditorNoticesManager } from "./managers/editor-notices-manager";
|
||||
import { registerDefaultEffects } from "@/effects";
|
||||
import { registerDefaultMasks } from "@/masks";
|
||||
import { registerTranscriptionDiagnostics } from "@/transcription/diagnostics";
|
||||
@@ -28,6 +29,7 @@ export class EditorCore {
|
||||
public readonly selection: SelectionManager;
|
||||
public readonly clipboard: ClipboardManager;
|
||||
public readonly diagnostics: DiagnosticsManager;
|
||||
public readonly notices: EditorNoticesManager;
|
||||
|
||||
private constructor() {
|
||||
registerDefaultEffects();
|
||||
@@ -44,6 +46,7 @@ export class EditorCore {
|
||||
this.selection = new SelectionManager(this);
|
||||
this.clipboard = new ClipboardManager(this);
|
||||
this.diagnostics = new DiagnosticsManager(this);
|
||||
this.notices = new EditorNoticesManager();
|
||||
registerTranscriptionDiagnostics({ diagnostics: this.diagnostics });
|
||||
this.playback.bindTimelineScope();
|
||||
this.command.registerReactor(() => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export type EditorNoticeTone = "info" | "warning" | "error";
|
||||
|
||||
export type EditorNoticeInput = {
|
||||
id: string;
|
||||
tone: EditorNoticeTone;
|
||||
message: string;
|
||||
dismissible?: boolean;
|
||||
};
|
||||
|
||||
export type EditorNotice = EditorNoticeInput & {
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export class EditorNoticesManager {
|
||||
private noticesByScope = new Map<string, EditorNotice[]>();
|
||||
private dismissedIds = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
private cachedNotices: EditorNotice[] = [];
|
||||
private cacheValid = false;
|
||||
|
||||
setScopeNotices({
|
||||
scope,
|
||||
notices,
|
||||
}: {
|
||||
scope: string;
|
||||
notices: EditorNoticeInput[];
|
||||
}): void {
|
||||
const nextNotices = notices.map((notice) => ({
|
||||
...notice,
|
||||
id: `${scope}:${notice.id}`,
|
||||
scope,
|
||||
}));
|
||||
const previousNotices = this.noticesByScope.get(scope) ?? [];
|
||||
if (areNoticeListsEqual(previousNotices, nextNotices)) {
|
||||
return;
|
||||
}
|
||||
if (nextNotices.length === 0) {
|
||||
this.noticesByScope.delete(scope);
|
||||
} else {
|
||||
this.noticesByScope.set(scope, nextNotices);
|
||||
}
|
||||
this.pruneStaleDismissals();
|
||||
this.invalidateCache();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearScope(scope: string): void {
|
||||
if (!this.noticesByScope.has(scope)) {
|
||||
return;
|
||||
}
|
||||
this.noticesByScope.delete(scope);
|
||||
this.pruneStaleDismissals();
|
||||
this.invalidateCache();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
dismiss({ id }: { id: string }): void {
|
||||
if (this.dismissedIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.dismissedIds.add(id);
|
||||
this.invalidateCache();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getNotices(): EditorNotice[] {
|
||||
if (!this.cacheValid) {
|
||||
this.cachedNotices = [...this.noticesByScope.values()]
|
||||
.flat()
|
||||
.filter((notice) => !this.dismissedIds.has(notice.id));
|
||||
this.cacheValid = true;
|
||||
}
|
||||
return this.cachedNotices;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.cacheValid = false;
|
||||
}
|
||||
|
||||
private pruneStaleDismissals(): void {
|
||||
if (this.dismissedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const liveIds = new Set<string>();
|
||||
for (const notices of this.noticesByScope.values()) {
|
||||
for (const notice of notices) {
|
||||
liveIds.add(notice.id);
|
||||
}
|
||||
}
|
||||
for (const id of this.dismissedIds) {
|
||||
if (!liveIds.has(id)) {
|
||||
this.dismissedIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function areNoticeListsEqual(
|
||||
left: EditorNotice[],
|
||||
right: EditorNotice[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((notice, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
notice.id === other?.id &&
|
||||
notice.scope === other.scope &&
|
||||
notice.tone === other.tone &&
|
||||
notice.message === other.message &&
|
||||
notice.dismissible === other.dismissible
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { createTimelineAudioBuffer } from "@/media/audio";
|
||||
import { formatTimecode } from "opencut-wasm";
|
||||
import { frameRateToFloat } from "@/fps/utils";
|
||||
import { downloadBlob } from "@/utils/browser";
|
||||
|
||||
type SnapshotResult =
|
||||
@@ -15,21 +14,10 @@ type SnapshotResult =
|
||||
|
||||
export class RendererManager {
|
||||
private renderTree: RootNode | null = null;
|
||||
private _isDegraded = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
get isDegraded(): boolean {
|
||||
return this._isDegraded;
|
||||
}
|
||||
|
||||
setDegraded(degraded: boolean): void {
|
||||
if (this._isDegraded === degraded) return;
|
||||
this._isDegraded = degraded;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
|
||||
this.renderTree = renderTree;
|
||||
this.notify();
|
||||
@@ -122,7 +110,9 @@ export class RendererManager {
|
||||
return { success: false, error: "Failed to create image" };
|
||||
}
|
||||
|
||||
const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-");
|
||||
const timecode =
|
||||
formatTimecode({ time: renderTime, rate: fps })?.replace(/:/g, "-") ??
|
||||
"unknown-time";
|
||||
const safeName =
|
||||
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
||||
"snapshot";
|
||||
|
||||
@@ -24,9 +24,9 @@ import type {
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveAnimationTarget,
|
||||
resolveAnimationPathValueAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { lastFrameTime } from "opencut-wasm";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import {
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useEditor<T>(
|
||||
editor.selection.subscribe(onChange),
|
||||
editor.clipboard.subscribe(onChange),
|
||||
editor.diagnostics.subscribe(onChange),
|
||||
editor.notices.subscribe(onChange),
|
||||
];
|
||||
return () => {
|
||||
unsubscribers.forEach((unsubscribe) => {
|
||||
|
||||
@@ -1,241 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { GraphicElement } from "@/timeline";
|
||||
import {
|
||||
graphicsRegistry,
|
||||
registerDefaultGraphics,
|
||||
resolveGraphicElementParamsAtTime,
|
||||
} from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
registerDefaultGraphics();
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 2;
|
||||
|
||||
export function GraphicTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{shapeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrokeSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||
|
||||
const toggleStroke = () => {
|
||||
if (isStrokeEnabled) {
|
||||
lastStrokeWidth.current = Number(
|
||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||
);
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: {
|
||||
params: {
|
||||
...element.params,
|
||||
strokeWidth: lastStrokeWidth.current,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={isStrokeEnabled}
|
||||
sectionKey={`${element.id}:stroke`}
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleStroke();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Stroke</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{strokeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedGraphicParamField({
|
||||
param,
|
||||
trackId,
|
||||
element,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedParams,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
element: GraphicElement;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||
{
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={resolvedParams[param.key] ?? param.default}
|
||||
onPreview={animatedParam.onPreview}
|
||||
onCommit={animatedParam.onCommit}
|
||||
keyframe={{
|
||||
isActive: animatedParam.isKeyframedAtTime,
|
||||
isDisabled: !isPlayheadWithinElementRange,
|
||||
onToggle: animatedParam.toggleKeyframe,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { GraphicElement } from "@/timeline";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
registerDefaultGraphics();
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 2;
|
||||
|
||||
export function GraphicTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{shapeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrokeSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = graphicsRegistry.get(element.definitionId);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const { renderElement } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const liveElement = renderElement as GraphicElement;
|
||||
const resolvedParams = resolveGraphicParamsAtTime({
|
||||
element: liveElement,
|
||||
localTime,
|
||||
});
|
||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||
|
||||
const toggleStroke = () => {
|
||||
if (isStrokeEnabled) {
|
||||
lastStrokeWidth.current = Number(
|
||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||
);
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
patch: {
|
||||
params: {
|
||||
...element.params,
|
||||
strokeWidth: lastStrokeWidth.current,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={isStrokeEnabled}
|
||||
sectionKey={`${element.id}:stroke`}
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleStroke();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Stroke</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{strokeParams.map((param) => (
|
||||
<AnimatedGraphicParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
trackId={trackId}
|
||||
element={liveElement}
|
||||
localTime={localTime}
|
||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||
resolvedParams={resolvedParams}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedGraphicParamField({
|
||||
param,
|
||||
trackId,
|
||||
element,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedParams,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
element: GraphicElement;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||
{
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={resolvedParams[param.key] ?? param.default}
|
||||
onPreview={animatedParam.onPreview}
|
||||
onCommit={animatedParam.onCommit}
|
||||
keyframe={{
|
||||
isActive: animatedParam.isKeyframedAtTime,
|
||||
isDisabled: !isPlayheadWithinElementRange,
|
||||
onToggle: animatedParam.toggleKeyframe,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+125
-149
@@ -1,149 +1,125 @@
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { graphicsRegistry } from "./registry";
|
||||
import {
|
||||
registerDefaultGraphics,
|
||||
ellipseGraphicDefinition,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
starGraphicDefinition,
|
||||
} from "./definitions";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
type GraphicInstance,
|
||||
type GraphicDefinition,
|
||||
} from "./types";
|
||||
|
||||
const graphicPreviewUrlCache = new Map<string, string>();
|
||||
|
||||
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
||||
const FALLBACK_FILL_OPACITY = 0.08;
|
||||
const FALLBACK_MIN_FONT_SIZE = 12;
|
||||
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
||||
|
||||
function buildFallbackPreviewUrl({
|
||||
name,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function getGraphicDefinition({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicDefinition {
|
||||
registerDefaultGraphics();
|
||||
return graphicsRegistry.get(definitionId);
|
||||
}
|
||||
|
||||
export function buildDefaultGraphicInstance({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicInstance {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
return {
|
||||
definitionId,
|
||||
params: buildDefaultParamValues(definition.params),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicParams(
|
||||
definition: GraphicDefinition,
|
||||
params?: ParamValues,
|
||||
): ParamValues {
|
||||
return {
|
||||
...buildDefaultParamValues(definition.params),
|
||||
...(params ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicElementParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
return resolveGraphicParamsAtTime({
|
||||
params: resolveGraphicParams(definition, element.params),
|
||||
definitions: definition.params,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGraphicPreviewUrl({
|
||||
definitionId,
|
||||
params,
|
||||
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
}: {
|
||||
definitionId: string;
|
||||
params?: ParamValues;
|
||||
size?: number;
|
||||
}): string {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
const resolvedParams = resolveGraphicParams(definition, params);
|
||||
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
||||
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
definition.render({
|
||||
ctx,
|
||||
params: resolvedParams,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
|
||||
const previewUrl = canvas.toDataURL("image/png");
|
||||
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
ellipseGraphicDefinition,
|
||||
graphicsRegistry,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
registerDefaultGraphics,
|
||||
starGraphicDefinition,
|
||||
};
|
||||
export type {
|
||||
GraphicDefinition,
|
||||
GraphicInstance,
|
||||
GraphicRenderContext,
|
||||
} from "./types";
|
||||
import { buildDefaultParamValues } from "@/params/registry";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { graphicsRegistry } from "./registry";
|
||||
import {
|
||||
registerDefaultGraphics,
|
||||
ellipseGraphicDefinition,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
starGraphicDefinition,
|
||||
} from "./definitions";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
type GraphicInstance,
|
||||
type GraphicDefinition,
|
||||
} from "./types";
|
||||
|
||||
const graphicPreviewUrlCache = new Map<string, string>();
|
||||
|
||||
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
||||
const FALLBACK_FILL_OPACITY = 0.08;
|
||||
const FALLBACK_MIN_FONT_SIZE = 12;
|
||||
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
||||
|
||||
function buildFallbackPreviewUrl({
|
||||
name,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
|
||||
</svg>
|
||||
`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function getGraphicDefinition({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicDefinition {
|
||||
registerDefaultGraphics();
|
||||
return graphicsRegistry.get(definitionId);
|
||||
}
|
||||
|
||||
export function buildDefaultGraphicInstance({
|
||||
definitionId,
|
||||
}: {
|
||||
definitionId: string;
|
||||
}): GraphicInstance {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
return {
|
||||
definitionId,
|
||||
params: buildDefaultParamValues(definition.params),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGraphicParams(
|
||||
definition: GraphicDefinition,
|
||||
params?: ParamValues,
|
||||
): ParamValues {
|
||||
return {
|
||||
...buildDefaultParamValues(definition.params),
|
||||
...(params ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphicPreviewUrl({
|
||||
definitionId,
|
||||
params,
|
||||
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
}: {
|
||||
definitionId: string;
|
||||
params?: ParamValues;
|
||||
size?: number;
|
||||
}): string {
|
||||
const definition = getGraphicDefinition({ definitionId });
|
||||
const resolvedParams = resolveGraphicParams(definition, params);
|
||||
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
||||
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||
}
|
||||
|
||||
definition.render({
|
||||
ctx,
|
||||
params: resolvedParams,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
|
||||
const previewUrl = canvas.toDataURL("image/png");
|
||||
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
ellipseGraphicDefinition,
|
||||
graphicsRegistry,
|
||||
polygonGraphicDefinition,
|
||||
rectangleGraphicDefinition,
|
||||
registerDefaultGraphics,
|
||||
starGraphicDefinition,
|
||||
};
|
||||
export type {
|
||||
GraphicDefinition,
|
||||
GraphicInstance,
|
||||
GraphicRenderContext,
|
||||
} from "./types";
|
||||
|
||||
@@ -188,21 +188,16 @@ function PreviewCanvas({
|
||||
);
|
||||
const frame = Math.floor(renderTime / ticksPerFrame);
|
||||
|
||||
if (
|
||||
frame === lastFrameRef.current &&
|
||||
renderTree === lastSceneRef.current
|
||||
) {
|
||||
if (frame === lastFrameRef.current && renderTree === lastSceneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderingRef.current = true;
|
||||
lastSceneRef.current = renderTree;
|
||||
lastFrameRef.current = frame;
|
||||
renderer
|
||||
.render({ node: renderTree, time: renderTime })
|
||||
.then(() => {
|
||||
renderingRef.current = false;
|
||||
});
|
||||
renderer.render({ node: renderTree, time: renderTime }).then(() => {
|
||||
renderingRef.current = false;
|
||||
});
|
||||
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
||||
|
||||
useRafLoop(render);
|
||||
@@ -302,20 +297,20 @@ function PreviewCanvas({
|
||||
ref={viewportRef}
|
||||
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={canvasMountRef}
|
||||
className="absolute block border"
|
||||
style={{
|
||||
left: viewport.sceneLeft,
|
||||
top: viewport.sceneTop,
|
||||
width: viewport.sceneWidth,
|
||||
height: viewport.sceneHeight,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={canvasMountRef}
|
||||
className="absolute block border"
|
||||
style={{
|
||||
left: viewport.sceneLeft,
|
||||
top: viewport.sceneTop,
|
||||
width: viewport.sceneWidth,
|
||||
height: viewport.sceneHeight,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewOverlayLayer
|
||||
instances={overlayInstances}
|
||||
plane="under-interaction"
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { resolveTextLayout } from "@/text/primitives";
|
||||
|
||||
export function TextEditOverlay({
|
||||
|
||||
@@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
|
||||
@@ -1,308 +1,308 @@
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import { measureTextElement } from "@/text/measure-element";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface ElementWithBounds {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TimelineElement;
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
function getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const containScale = Math.min(
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
function getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const localCenterX = rect.left + rect.width / 2;
|
||||
const localCenterY = rect.top + rect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scaleX;
|
||||
const scaledCenterY = localCenterY * transform.scaleY;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
return {
|
||||
cx:
|
||||
canvasWidth / 2 +
|
||||
transform.position.x +
|
||||
scaledCenterX * cos -
|
||||
scaledCenterY * sin,
|
||||
cy:
|
||||
canvasHeight / 2 +
|
||||
transform.position.y +
|
||||
scaledCenterX * sin +
|
||||
scaledCenterY * cos,
|
||||
width: rect.width * transform.scaleX,
|
||||
height: rect.height * transform.scaleY,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||
* the canonical element geometry, not visual effect output.
|
||||
*/
|
||||
function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio" || element.type === "effect") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const measured = measureTextElement({
|
||||
element,
|
||||
canvasHeight,
|
||||
localTime,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect: measured.visualRect,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROTATION_HANDLE_OFFSET = 24;
|
||||
|
||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type Edge = "right" | "left" | "bottom";
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeHandlePosition({
|
||||
bounds,
|
||||
edge,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
edge: Edge;
|
||||
}): { x: number; y: number } {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||
const localY = edge === "bottom" ? halfHeight : 0;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
currentTime: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementWithBounds[] {
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
const orderedTracks = [
|
||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||
].reverse();
|
||||
|
||||
const result: ElementWithBounds[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.filter(
|
||||
(element) =>
|
||||
currentTime >= element.startTime &&
|
||||
currentTime < element.startTime + element.duration,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
: undefined;
|
||||
const bounds = getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
element,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import { measureTextElement } from "@/text/measure-element";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface ElementWithBounds {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TimelineElement;
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
function getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const containScale = Math.min(
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
function getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const localCenterX = rect.left + rect.width / 2;
|
||||
const localCenterY = rect.top + rect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scaleX;
|
||||
const scaledCenterY = localCenterY * transform.scaleY;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
return {
|
||||
cx:
|
||||
canvasWidth / 2 +
|
||||
transform.position.x +
|
||||
scaledCenterX * cos -
|
||||
scaledCenterY * sin,
|
||||
cy:
|
||||
canvasHeight / 2 +
|
||||
transform.position.y +
|
||||
scaledCenterX * sin +
|
||||
scaledCenterY * cos,
|
||||
width: rect.width * transform.scaleX,
|
||||
height: rect.height * transform.scaleY,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||
* the canonical element geometry, not visual effect output.
|
||||
*/
|
||||
function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
localTime: number;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio" || element.type === "effect") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const measured = measureTextElement({
|
||||
element,
|
||||
canvasHeight,
|
||||
localTime,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect: measured.visualRect,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROTATION_HANDLE_OFFSET = 24;
|
||||
|
||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type Edge = "right" | "left" | "bottom";
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeHandlePosition({
|
||||
bounds,
|
||||
edge,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
edge: Edge;
|
||||
}): { x: number; y: number } {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||
const localY = edge === "bottom" ? halfHeight : 0;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
currentTime: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementWithBounds[] {
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
const orderedTracks = [
|
||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||
].reverse();
|
||||
|
||||
const result: ElementWithBounds[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.filter(
|
||||
(element) =>
|
||||
currentTime >= element.startTime &&
|
||||
currentTime < element.startTime + element.duration,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
: undefined;
|
||||
const bounds = getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
localTime,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
element,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { resolveAnimationPathValueAtTime } from "@/animation";
|
||||
import type { Transform } from "./index";
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/animation/values";
|
||||
import { resolveOpacityAtTime } from "@/animation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
import {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
import {
|
||||
applyEffectPasses,
|
||||
applyMaskFeather as applyMaskFeatherWasm,
|
||||
GpuInitKind,
|
||||
initializeGpu,
|
||||
} from "opencut-wasm";
|
||||
import type { EffectPass, EffectUniformValue } from "@/effects/types";
|
||||
|
||||
let gpuAvailable = false;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
export type GpuRendererInitializationResult =
|
||||
| { kind: "ready" }
|
||||
| { kind: "software-fallback-adapter" }
|
||||
| { kind: "webgl-fallback" }
|
||||
| { kind: "unavailable"; message: string };
|
||||
|
||||
export function initializeGpuRenderer(): Promise<void> {
|
||||
type GpuInitDiscriminator = Exclude<
|
||||
GpuRendererInitializationResult,
|
||||
{ kind: "unavailable" }
|
||||
>["kind"];
|
||||
|
||||
let gpuAvailable = false;
|
||||
let initPromise: Promise<GpuRendererInitializationResult> | null = null;
|
||||
|
||||
export function initializeGpuRenderer(): Promise<GpuRendererInitializationResult> {
|
||||
if (!initPromise) {
|
||||
initPromise = initializeGpu()
|
||||
.then(() => {
|
||||
.then((kind): GpuRendererInitializationResult => {
|
||||
gpuAvailable = true;
|
||||
return { kind: gpuInitKindToDiscriminator(kind) };
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
.catch((error: unknown): GpuRendererInitializationResult => {
|
||||
gpuAvailable = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`GPU renderer unavailable: ${message}`);
|
||||
return { kind: "unavailable", message };
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function isGpuAvailable(): boolean {
|
||||
return gpuAvailable;
|
||||
function gpuInitKindToDiscriminator(kind: GpuInitKind): GpuInitDiscriminator {
|
||||
switch (kind) {
|
||||
case GpuInitKind.Ready:
|
||||
return "ready";
|
||||
case GpuInitKind.SoftwareFallbackAdapter:
|
||||
return "software-fallback-adapter";
|
||||
case GpuInitKind.WebglFallback:
|
||||
return "webgl-fallback";
|
||||
}
|
||||
const unhandled: never = kind;
|
||||
throw new Error(`Unhandled GpuInitKind: ${unhandled}`);
|
||||
}
|
||||
|
||||
export const gpuRenderer = {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveGraphicParamsAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
||||
import {
|
||||
buildGaussianBlurPasses,
|
||||
@@ -8,16 +14,11 @@ import {
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
||||
import type { Effect, EffectPass } from "@/effects/types";
|
||||
import { getSourceTimeAtClipTime } from "@/retime";
|
||||
import {
|
||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
resolveGraphicElementParamsAtTime,
|
||||
} from "@/graphics";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||
import {
|
||||
getTextMeasurementContext,
|
||||
measureTextElement,
|
||||
} from "@/text/measure-element";
|
||||
import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import type { CanvasRenderer } from "./canvas-renderer";
|
||||
import type { AnyBaseNode } from "./nodes/base-node";
|
||||
@@ -112,8 +113,7 @@ function resolveEffectPassGroups({
|
||||
.filter((effect) => effect.enabled)
|
||||
.map((effect) => {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effectId: effect.id,
|
||||
params: effect.params,
|
||||
effect,
|
||||
animations,
|
||||
localTime,
|
||||
});
|
||||
@@ -304,7 +304,7 @@ function resolveGraphicNode({
|
||||
|
||||
return {
|
||||
...visualState,
|
||||
resolvedParams: resolveGraphicElementParamsAtTime({
|
||||
resolvedParams: resolveGraphicParamsAtTime({
|
||||
element: node.params,
|
||||
localTime: visualState.localTime,
|
||||
}),
|
||||
|
||||
@@ -30,10 +30,7 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
||||
import {
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
} from "@/animation/values";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
MinusSignIcon,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CORNER_RADIUS_MIN } from "@/text/background";
|
||||
import { resolveNumberAtTime } from "@/animation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import type { TextBackground, TextElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import {
|
||||
getTextVisualRect,
|
||||
} from "./layout";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hasKeyframesForPath } from "@/animation/keyframe-query";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import { resolveNumberAtTime } from "@/animation/resolve";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||
import type { TimelineElement } from "./types";
|
||||
const DEFAULT_STEP_SECONDS = 1 / 60;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { getBookmarkSnapPoints } from "../snap-source";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import type { Bookmark } from "@/timeline";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
|
||||
@@ -1,151 +1,151 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation/values";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
export function AudioTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedVolume = resolveNumberAtTime({
|
||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
});
|
||||
const volume = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: formatNumberForDisplay({
|
||||
value: resolvedVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
}),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clamp({
|
||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
},
|
||||
valueAtPlayhead: resolvedVolume,
|
||||
step: VOLUME_STEP,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
volume: value,
|
||||
}),
|
||||
});
|
||||
const isDefault =
|
||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||
? isNearlyEqual({
|
||||
leftValue: resolvedVolume,
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
export function AudioTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedVolume = resolveNumberAtTime({
|
||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
});
|
||||
const volume = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: formatNumberForDisplay({
|
||||
value: resolvedVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
}),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clamp({
|
||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
},
|
||||
valueAtPlayhead: resolvedVolume,
|
||||
step: VOLUME_STEP,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
volume: value,
|
||||
}),
|
||||
});
|
||||
const isDefault =
|
||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||
? isNearlyEqual({
|
||||
leftValue: resolvedVolume,
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToPixels,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
isRetimableElement,
|
||||
type SceneTracks,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import type { MoveGroup } from "./types";
|
||||
|
||||
export function snapGroupEdges({
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Primitives vs domains
|
||||
|
||||
The codebase has a recurring smell: **primitive value types defined inside
|
||||
domain folders**. The clearest current example is `Transform`, which lives in
|
||||
`apps/web/src/rendering/index.ts`. Rendering happens to consume it — but so do
|
||||
`@/timeline`, `@/preview`, `@/animation`, `@/text`, and anything else that
|
||||
positions things on a 2D canvas. It's not "of" rendering; rendering just owns
|
||||
the file.
|
||||
|
||||
## The test
|
||||
|
||||
If a type can be described without mentioning clips, tracks, effects, layers,
|
||||
keyframes, or any other product concept — and it has no behavior beyond shape
|
||||
— it's a **primitive**. The moment a type needs to know what a clip is, it
|
||||
has crossed into domain territory.
|
||||
|
||||
Primitives have:
|
||||
|
||||
- No domain-specific invariants (a 2D position doesn't care that it's a clip's
|
||||
position; it's just `{ x, y }`).
|
||||
- No dependencies on other parts of the app — they're leaves.
|
||||
- Multiple unrelated consumers across domains.
|
||||
- A name that would make sense in any 2D editor / video tool / graphics lib.
|
||||
|
||||
Domains, in contrast, can name things that only make sense given the rest of
|
||||
the product (`TimelineElement`, `Effect`, `GraphicDefinition`, `MediaAsset`).
|
||||
|
||||
## Why it matters
|
||||
|
||||
When a primitive lives in a domain folder, every other domain that consumes it
|
||||
takes a misleading dependency — `@/timeline` ends up importing from
|
||||
`@/rendering` not because timeline needs rendering, but because that's where
|
||||
`Transform` happens to sit. The dependency graph lies, and pieces that should
|
||||
move freely become anchored to the wrong layer.
|
||||
|
||||
## The refactor
|
||||
|
||||
Move primitives out of domain folders into a primitives location (somewhere
|
||||
like `apps/web/src/primitives/`, or split by concern — `geometry/`, `time/`,
|
||||
`color/`, etc.). Whatever the bucket, the rule is "no product concepts, no
|
||||
behavior, no upward dependencies".
|
||||
|
||||
Don't bulk-move. Each move is deliberate — the right destination depends on
|
||||
what other primitives already exist and what naming convention has emerged.
|
||||
|
||||
## Side effects to watch for
|
||||
|
||||
Files often end up parked next to misplaced primitives because they had nowhere
|
||||
better to live. Example: `apps/web/src/rendering/animation-values.ts` exists
|
||||
only because `Transform` lives next door. Once `Transform` moves to a primitive
|
||||
location, that file collapses back into `apps/web/src/animation/values.ts`
|
||||
alongside the other resolve-at-time helpers — there's no remaining reason to
|
||||
split them.
|
||||
|
||||
When moving a primitive, look at what *else* in its current folder only exists
|
||||
because of that primitive. Those usually want to move too (or merge somewhere
|
||||
else once the anchor is gone).
|
||||
@@ -3,7 +3,6 @@ use effects::{ApplyEffectsOptions, EffectPass, EffectPipeline, UniformValue};
|
||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext, wgpu};
|
||||
use masks::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
|
||||
use thiserror::Error;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{
|
||||
BlendMode,
|
||||
@@ -589,23 +588,20 @@ impl Compositor {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("compositor-layer-uniform-buffer"),
|
||||
contents: bytemuck::bytes_of(&LayerUniformBuffer {
|
||||
resolution: [width as f32, height as f32],
|
||||
center: [layer.transform.center_x, layer.transform.center_y],
|
||||
size: [layer.transform.width, layer.transform.height],
|
||||
rotation_radians: layer.transform.rotation_degrees.to_radians(),
|
||||
opacity: layer.opacity,
|
||||
flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 },
|
||||
flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 },
|
||||
_padding: [0.0; 2],
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"compositor-layer-uniform-buffer",
|
||||
bytemuck::bytes_of(&LayerUniformBuffer {
|
||||
resolution: [width as f32, height as f32],
|
||||
center: [layer.transform.center_x, layer.transform.center_y],
|
||||
size: [layer.transform.width, layer.transform.height],
|
||||
rotation_radians: layer.transform.rotation_degrees.to_radians(),
|
||||
opacity: layer.opacity,
|
||||
flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 },
|
||||
flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 },
|
||||
_padding: [0.0; 2],
|
||||
}),
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group = context
|
||||
.device()
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -691,17 +687,14 @@ impl Compositor {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("compositor-mask-uniform-buffer"),
|
||||
contents: bytemuck::bytes_of(&MaskUniformBuffer {
|
||||
inverted: if inverted { 1.0 } else { 0.0 },
|
||||
_padding: [0.0; 3],
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"compositor-mask-uniform-buffer",
|
||||
bytemuck::bytes_of(&MaskUniformBuffer {
|
||||
inverted: if inverted { 1.0 } else { 0.0 },
|
||||
_padding: [0.0; 3],
|
||||
}),
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group = context
|
||||
.device()
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -788,17 +781,14 @@ impl Compositor {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("compositor-blend-uniform-buffer"),
|
||||
contents: bytemuck::bytes_of(&BlendUniformBuffer {
|
||||
blend_mode: blend_mode.shader_code(),
|
||||
_padding: [0; 3],
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"compositor-blend-uniform-buffer",
|
||||
bytemuck::bytes_of(&BlendUniformBuffer {
|
||||
blend_mode: blend_mode.shader_code(),
|
||||
_padding: [0; 3],
|
||||
}),
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group = context
|
||||
.device()
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::collections::HashMap;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||
use thiserror::Error;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{EffectPass, UniformValue};
|
||||
|
||||
@@ -206,14 +205,11 @@ impl EffectPipeline {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("effects-uniform-buffer"),
|
||||
contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"effects-uniform-buffer",
|
||||
bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group =
|
||||
context
|
||||
.device()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -37,6 +35,52 @@ const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
||||
[1.0, 1.0],
|
||||
];
|
||||
|
||||
/// Creates a buffer and uploads `contents` via `queue.write_buffer` instead of
|
||||
/// `mapped_at_creation: true`. Chrome's WebGPU backend has been rejecting the
|
||||
/// mapped-at-creation path in our render loop, so this helper exists to keep
|
||||
/// every buffer creation on the queue-write path.
|
||||
///
|
||||
/// `usage` MUST include `COPY_DST` whenever `contents` is non-empty; otherwise
|
||||
/// `queue.write_buffer` will fail validation at runtime.
|
||||
fn create_buffer_with_data(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &'static str,
|
||||
contents: &[u8],
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
debug_assert!(
|
||||
contents.is_empty() || usage.contains(wgpu::BufferUsages::COPY_DST),
|
||||
"create_buffer_with_data requires COPY_DST when contents are non-empty (label: {label})",
|
||||
);
|
||||
|
||||
let unpadded_size = contents.len() as wgpu::BufferAddress;
|
||||
let padded_size = if contents.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let align_mask = wgpu::COPY_BUFFER_ALIGNMENT - 1;
|
||||
((unpadded_size + align_mask) & !align_mask).max(wgpu::COPY_BUFFER_ALIGNMENT)
|
||||
};
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: padded_size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
if !contents.is_empty() {
|
||||
if padded_size == unpadded_size {
|
||||
queue.write_buffer(&buffer, 0, contents);
|
||||
} else {
|
||||
let mut padded_contents = vec![0; padded_size as usize];
|
||||
padded_contents[..unpadded_size as usize].copy_from_slice(contents);
|
||||
queue.write_buffer(&buffer, 0, &padded_contents);
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
pub struct GpuContext {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -68,11 +112,13 @@ impl GpuContext {
|
||||
} else {
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
};
|
||||
let fullscreen_quad = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("gpu-fullscreen-quad-buffer"),
|
||||
contents: bytemuck::cast_slice(&FULLSCREEN_QUAD_POSITIONS),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let fullscreen_quad = create_buffer_with_data(
|
||||
&device,
|
||||
&queue,
|
||||
"gpu-fullscreen-quad-buffer",
|
||||
bytemuck::cast_slice(&FULLSCREEN_QUAD_POSITIONS),
|
||||
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("gpu-linear-sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
@@ -317,6 +363,15 @@ impl GpuContext {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_buffer_with_data(
|
||||
&self,
|
||||
label: &'static str,
|
||||
contents: &[u8],
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
create_buffer_with_data(&self.device, &self.queue, label, contents, usage)
|
||||
}
|
||||
|
||||
pub fn instance(&self) -> &wgpu::Instance {
|
||||
&self.instance
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::SdfPipeline;
|
||||
|
||||
@@ -234,18 +233,15 @@ impl MaskFeatherPipeline {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("gpu-mask-distance-uniform-buffer"),
|
||||
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
|
||||
resolution: [width as f32, height as f32],
|
||||
feather_half: feather / 2.0,
|
||||
_padding: 0.0,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"gpu-mask-distance-uniform-buffer",
|
||||
bytemuck::bytes_of(&DistanceUniformBuffer {
|
||||
resolution: [width as f32, height as f32],
|
||||
feather_half: feather / 2.0,
|
||||
_padding: 0.0,
|
||||
}),
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group = context
|
||||
.device()
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use gpu::{FULLSCREEN_SHADER_SOURCE, GpuContext};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
|
||||
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
|
||||
@@ -243,14 +242,11 @@ impl SdfPipeline {
|
||||
},
|
||||
],
|
||||
});
|
||||
let uniform_buffer =
|
||||
context
|
||||
.device()
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("gpu-sdf-uniform-buffer"),
|
||||
contents: uniform_buffer_bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let uniform_buffer = context.create_buffer_with_data(
|
||||
"gpu-sdf-uniform-buffer",
|
||||
uniform_buffer_bytes,
|
||||
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let uniform_bind_group = context
|
||||
.device()
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
edition = "2024"
|
||||
description = "Shared video editor logic compiled to WebAssembly"
|
||||
repository = "https://github.com/opencut/opencut"
|
||||
|
||||
+41
-24
@@ -9,45 +9,48 @@ use masks::MaskFeatherPipeline;
|
||||
use serde::Deserialize;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
||||
|
||||
/// Classifies the GPU path that initialization landed on, so the UI can decide
|
||||
/// whether to surface a degraded-rendering notice. This is the single source of
|
||||
/// truth — JS never calls `requestAdapter()` itself to figure this out.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum GpuInitKind {
|
||||
/// WebGPU on a hardware adapter.
|
||||
Ready,
|
||||
/// WebGPU but on a CPU/software adapter (e.g. SwiftShader). Preview will
|
||||
/// likely be slow or blank.
|
||||
SoftwareFallbackAdapter,
|
||||
/// WebGPU was unavailable; we fell back to WebGL. Works but with reduced
|
||||
/// capability and performance.
|
||||
WebglFallback,
|
||||
}
|
||||
|
||||
pub(crate) struct GpuRuntime {
|
||||
pub(crate) context: GpuContext,
|
||||
pub(crate) effects: EffectPipeline,
|
||||
pub(crate) masks: MaskFeatherPipeline,
|
||||
kind: GpuInitKind,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static GPU_RUNTIME: RefCell<Option<GpuRuntime>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
static SET_HOOK: std::sync::Once = std::sync::Once::new();
|
||||
SET_HOOK.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
// Store the full panic message in window.__wasmPanic so the JS catch block
|
||||
// can surface it instead of the opaque "Unreachable" WASM trap message.
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = Reflect::set(
|
||||
&window,
|
||||
&JsValue::from_str("__wasmPanic"),
|
||||
&JsValue::from_str(&info.to_string()),
|
||||
);
|
||||
}
|
||||
console_error_panic_hook::hook(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Initializes the shared GPU runtime and reports which path it landed on.
|
||||
///
|
||||
/// Concurrent callers are expected to be serialized by the JS layer (it caches
|
||||
/// the returned promise). Without that, two parallel calls would each create a
|
||||
/// `GpuContext` and the second would silently overwrite the first.
|
||||
#[wasm_bindgen(js_name = initializeGpu)]
|
||||
pub async fn initialize_gpu() -> Result<(), JsValue> {
|
||||
set_panic_hook();
|
||||
|
||||
if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) {
|
||||
return Ok(());
|
||||
pub async fn initialize_gpu() -> Result<GpuInitKind, JsValue> {
|
||||
if let Some(kind) = GPU_RUNTIME.with(|runtime| runtime.borrow().as_ref().map(|r| r.kind)) {
|
||||
return Ok(kind);
|
||||
}
|
||||
|
||||
let context = GpuContext::new()
|
||||
.await
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
let kind = classify_adapter(context.adapter());
|
||||
let effects = EffectPipeline::new(&context);
|
||||
let masks = MaskFeatherPipeline::new(&context);
|
||||
|
||||
@@ -56,10 +59,24 @@ pub async fn initialize_gpu() -> Result<(), JsValue> {
|
||||
context,
|
||||
effects,
|
||||
masks,
|
||||
kind,
|
||||
}));
|
||||
});
|
||||
|
||||
Ok(())
|
||||
Ok(kind)
|
||||
}
|
||||
|
||||
fn classify_adapter(adapter: &wgpu::Adapter) -> GpuInitKind {
|
||||
let info = adapter.get_info();
|
||||
if info.backend == wgpu::Backend::Gl {
|
||||
GpuInitKind::WebglFallback
|
||||
} else if info.device_type == wgpu::DeviceType::Cpu {
|
||||
GpuInitKind::SoftwareFallbackAdapter
|
||||
} else {
|
||||
// `DeviceType::Other` is bucketed with hardware adapters; some
|
||||
// virtualized GPUs report this way and we have no better signal.
|
||||
GpuInitKind::Ready
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_gpu_runtime<T>(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
//! Wasm panic hook.
|
||||
//!
|
||||
//! Forwards Rust panics to the browser console via `console_error_panic_hook`,
|
||||
//! and additionally stashes the formatted panic message on `window.__wasmPanic`
|
||||
//! so JS catch blocks can surface the real cause instead of the opaque
|
||||
//! "Unreachable" wasm trap that wasm-bindgen otherwise produces.
|
||||
|
||||
use js_sys::Reflect;
|
||||
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn install() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = Reflect::set(
|
||||
&window,
|
||||
&JsValue::from_str("__wasmPanic"),
|
||||
&JsValue::from_str(&info.to_string()),
|
||||
);
|
||||
}
|
||||
console_error_panic_hook::hook(info);
|
||||
}));
|
||||
}
|
||||
@@ -7,6 +7,8 @@ mod gpu;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod masks;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod panic;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod perf;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
Reference in New Issue
Block a user