diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index 3f277d40..7f5ac08b 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -1,149 +1,149 @@ -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(); - -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 = ` - - - ${name} - - `; - 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 { 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(); + +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 = ` + + + ${name} + + `; + 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"; diff --git a/apps/web/src/rendering/NOTES.md b/apps/web/src/rendering/NOTES.md deleted file mode 100644 index 38055c14..00000000 --- a/apps/web/src/rendering/NOTES.md +++ /dev/null @@ -1,27 +0,0 @@ -# Notes - -## `Transform` is misplaced - -`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's -exported from the rendering domain because rendering happens to be one of its -consumers — but every other domain that touches scene geometry consumes it too -(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not -"of" rendering any more than `Vector2` would be "of" math. - -`Transform` is closer to a primitive than a domain concept. It's infrastructure: -a small value type with no behavior, no dependencies, no domain-specific -invariants. Anything that wants to position something on a 2D canvas needs it. - -The codebase should be refactored so it's defined lower-level, rather than -sitting in a domain. Candidate homes: - -- `apps/web/src/primitives/transform.ts` (new dir for these value types) -- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there -- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering` -becomes a primitives folder rather than a domain (it's borderline today — -it also exports `BlendMode`, which has the same problem). - -Side effect of fixing this: `@/rendering/animation-values.ts` (which exists -only because `Transform` lives next to it) can move into `@/animation/values.ts` -alongside the other resolve-at-time helpers, since `Transform` would no longer -pull in a domain dependency. \ No newline at end of file diff --git a/notes/primitives-vs-domains.md b/notes/primitives-vs-domains.md new file mode 100644 index 00000000..6708246c --- /dev/null +++ b/notes/primitives-vs-domains.md @@ -0,0 +1,57 @@ +# 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). \ No newline at end of file