Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2d19702
build: extend tsconfig paths and vite config aliases
AIEraDev Aug 28, 2026
d3a4e25
feat(render): add textRenderTrace utility for render diagnostics
AIEraDev Aug 28, 2026
9f18846
feat(evaluation): extend EvaluatedTextLayer types with revision and s…
AIEraDev Aug 28, 2026
be70d93
feat(evaluation): pass revisionId and canonicalScene through evaluator
AIEraDev Aug 28, 2026
ea919c1
feat(render): use canonical scene for native rasterization path
AIEraDev Aug 28, 2026
7497039
feat(preview): use canonical scene in TextSourcePreview for Source/Pr…
AIEraDev Aug 28, 2026
2f0819c
feat(text-effects): fetch revision-aware URLs and read ETag/X-Clypra-…
AIEraDev Aug 28, 2026
480f14d
feat(text-effects): key persistent cache entries by revisionId+conten…
AIEraDev Aug 28, 2026
083e818
feat(text-effects): update cacheManager to route through revision-awa…
AIEraDev Aug 28, 2026
4710765
feat(text-effects): renderer consumes canonical scene from evaluated …
AIEraDev Aug 28, 2026
77c7d17
feat(text-effects): store revisionId and contentHash on loaded effects
AIEraDev Aug 28, 2026
06bfd06
test(text-effects): extend renderer tests for canonical scene and dis…
AIEraDev Aug 28, 2026
b849c2e
feat(text-templates): snapshot revisionId and dependencies when insta…
AIEraDev Aug 28, 2026
18cb5da
test(text-templates): add revision snapshot and dependency tests for …
AIEraDev Aug 28, 2026
5105f5a
feat(text): preserve canonicalScene on timeline clip save and reload
AIEraDev Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/components/editor/preview/TextSourcePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useCallback } from "react";
import { TemplatePreviewPlayer } from "@/features/text-templates";
import { evaluateScene, textEffectConfigToScene, type TextEffectConfig, _buildConfig } from "@clypra-studio/engine";
import { getFontLoader } from "@/core/fonts/FontLoader";
import { traceTextRenderScene } from "@/core/render/textRenderTrace";

// Effects are designed for this banner canvas size (800×200).
const PREVIEW_CANVAS_W = 800;
Expand Down Expand Up @@ -153,9 +154,26 @@ export const TextSourcePreview: React.FC<TextSourcePreviewProps> = ({ preset })
scene.text.lineHeight = effectConfig.lineHeight;
scene.canvas.width = PREVIEW_CANVAS_W;
scene.canvas.height = PREVIEW_CANVAS_H;
traceTextRenderScene(scene, {
path: "source-preview",
assetId: (preset as any).id,
category: (preset as any).category,
revisionId: (preset as any).revisionId,
contentHash: (preset as any).contentHash,
time: 0,
});
evaluateScene(scene, 0, ctx);
} else {
evaluateScene(textEffectConfigToScene(effectConfig), 0, ctx);
const scene = textEffectConfigToScene(effectConfig);
traceTextRenderScene(scene, {
path: "source-preview",
assetId: (preset as any).id,
category: (preset as any).category,
revisionId: (preset as any).revisionId,
contentHash: (preset as any).contentHash,
time: 0,
});
evaluateScene(scene, 0, ctx);
}
} catch (error) {
console.error("[TextSourcePreview] ❌ Error:", error);
Expand Down
6 changes: 6 additions & 0 deletions src/core/evaluation/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export function evaluateTimelineScene(time: number, clips: Clip[], tracks: Track
const catalogStyleDefinition = resolveTextEffectDefinition(
textClip.styleId,
textClip.styleDefinition,
textClip.styleRevisionId,
textClip.styleContentHash,
);
const styleDefinition = catalogStyleDefinition || textClip.styleSnapshot
? ({
Expand Down Expand Up @@ -218,6 +220,10 @@ export function evaluateTimelineScene(time: number, clips: Clip[], tracks: Track
parameterOverrides: textClip.parameterOverrides,
styleDefinition,
templateId: textClip.templateId,
templateRevisionId: textClip.templateRevisionId,
templateContentHash: textClip.templateContentHash,
templateSnapshot: textClip.templateSnapshot,
templateDependencies: textClip.templateDependencies,
customization: textClip.customization,
};

Expand Down
9 changes: 9 additions & 0 deletions src/core/evaluation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ export interface EvaluatedTextLayer extends BaseVisualLayer {

/** Template-specific settings */
readonly templateId?: string;
readonly templateRevisionId?: string;
readonly templateContentHash?: string;
readonly templateSnapshot?: import("@clypra-studio/engine").TextTemplate;
readonly templateDependencies?: ReadonlyArray<{
effectId: string;
revisionId: string;
contentHash: string;
snapshot?: import("@clypra-studio/engine").SceneDocument;
}>;
readonly customization?: any;
}

Expand Down
90 changes: 77 additions & 13 deletions src/core/render/textRasterizer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { EvaluatedTextLayer } from "../evaluation/types";
import { evaluateScene as engineEvaluateScene, textEffectConfigToScene, type TextEffectConfig, layerToTextEffectConfig, CanvasDevice, defaultConfig as engineDefaultConfig, _buildConfig } from "@clypra-studio/engine";
import { evaluateScene as engineEvaluateScene, textEffectConfigToScene, type TextEffectConfig, layerToTextEffectConfig, CanvasDevice, defaultConfig as engineDefaultConfig, _buildConfig, normalizeTextTemplate } from "@clypra-studio/engine";
import { useEffectsStore } from "../../features/text-effects/store/effectsStore";
import { invalidateEvaluationCache } from "../evaluation/evaluator";
import { useTimelineStore } from "../../store/timelineStore";
import { effectBleed, resolveTextEffectDefinition } from "../../lib/text/textClip";
import { getTextRenderMetrics, normalizeFontSize } from "../../lib/utils/fixedSizing";
import { traceTextRenderScene } from "./textRenderTrace";



Expand Down Expand Up @@ -72,12 +73,27 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
}
}
const rawTemplate = templates.find((t) => t.id === layer.templateId);
let template = rawTemplate?.templateData || rawTemplate?.lottieData;
// Existing timeline instances are immutable snapshots. Resolve them
// before consulting the live catalog so a Studio republish cannot change
// the appearance of an already-created clip.
let template = (layer.templateSnapshot?.layers?.length || (layer.templateSnapshot as any)?.elements?.length)
? layer.templateSnapshot
: undefined;

const requestedRevisionId = layer.templateRevisionId;
const catalogRevisionId = (rawTemplate as any)?.revisionId ?? (rawTemplate as any)?.revision?.revisionId;
if (!template && rawTemplate && (!requestedRevisionId || requestedRevisionId === catalogRevisionId)) {
template = rawTemplate.templateData || rawTemplate.lottieData;
}

if (rawTemplate && !template) {
try {
const { TextEffectsApi } = await import("@/features/text-effects/api/textEffectsApi");
const templateData = await TextEffectsApi.getTemplateData(rawTemplate.category, rawTemplate.id);
const templateData = await TextEffectsApi.getTemplateData(
rawTemplate.category,
rawTemplate.id,
requestedRevisionId ? { revisionId: requestedRevisionId } : {},
);
useTemplateStore.setState((state) => ({
templates: state.templates.map((t) => (t.id === rawTemplate.id ? { ...t, templateData, lottieData: templateData } : t)),
}));
Expand All @@ -89,7 +105,12 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
}
}

if (template && template.layers) {
if (template && (template.layers?.length || (template as any).elements?.length)) {
// Normalize element-based Studio payloads once before rendering. This
// also makes the pinned snapshot path use the exact same layer model as
// the live API/template preview path.
const activeTemplate = normalizeTextTemplate(template) as any;
template = activeTemplate;
const customization = layer.customization || {
primaryText: layer.text || "",
secondaryText: "",
Expand All @@ -99,10 +120,10 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
};

const { TemplateRenderer } = await import("@clypra-studio/engine");
const renderer = new TemplateRenderer(template);
const renderer = new TemplateRenderer(activeTemplate);

// Apply customization overrides to the renderer
for (const tLayer of template.layers) {
for (const tLayer of activeTemplate.layers) {
if (tLayer.kind === "text") {
const changes: any = {};

Expand Down Expand Up @@ -159,9 +180,9 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
const localTime = layer.time !== undefined && layer.clipStartTime !== undefined ? layer.time - layer.clipStartTime : 0;

// Get the bounds of the actual template content to scale it relative to the content rather than the empty canvas
const tempCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(template.canvasWidth, template.canvasHeight) : document.createElement("canvas");
tempCanvas.width = template.canvasWidth;
tempCanvas.height = template.canvasHeight;
const tempCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(activeTemplate.canvasWidth, activeTemplate.canvasHeight) : document.createElement("canvas");
tempCanvas.width = activeTemplate.canvasWidth;
tempCanvas.height = activeTemplate.canvasHeight;
const tempCtx = tempCanvas.getContext("2d");
if (tempCtx) {
renderer.drawFrame(tempCtx, localTime, { skipClear: true });
Expand All @@ -185,8 +206,8 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
ctx.scale(scale, scale);
ctx.translate(-bounds.x + offsetX / scale, -bounds.y + offsetY / scale);
} else {
const sX = width / template.canvasWidth;
const sY = height / template.canvasHeight;
const sX = width / activeTemplate.canvasWidth;
const sY = height / activeTemplate.canvasHeight;
ctx.scale(sX, sY);
}

Expand All @@ -203,10 +224,15 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
// text renders at wrong size after resize operations.
// We DO apply scale to geometric properties (bleed, stroke, shadow) for quality independence.
const fontSize = layer.fontSize; // Use fontSize directly from layer state
const effectDef = resolveTextEffectDefinition(
const resolvedEffectDef = resolveTextEffectDefinition(
layer.styleId,
layer.styleDefinition,
layer.styleRevisionId,
layer.styleContentHash,
);
const effectDef = resolvedEffectDef && layer.styleSnapshot
? ({ ...resolvedEffectDef, scene: layer.styleSnapshot } as any)
: resolvedEffectDef;
const declaredBleed = effectBleed({
styleId: layer.styleId,
effectDefinition: effectDef,
Expand Down Expand Up @@ -262,7 +288,38 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
canonicalScene.text.textPosY = layer.verticalAlign === "middle" ? "middle" : layer.verticalAlign || canonicalScene.text.textPosY;
canonicalScene.canvas.width = unscaledOffW;
canonicalScene.canvas.height = unscaledOffH;
engineEvaluateScene(canonicalScene, layer.time ?? 0, ctx);
traceTextRenderScene(canonicalScene, {
path: "program-preview",
assetId: layer.styleId,
revisionId: (effectDef as any).revisionId,
contentHash: (effectDef as any).contentHash,
time: layer.time ?? 0,
});
// Render canonical scenes into an isolated raster just like the
// compatibility path below. Directly evaluating into the caller's
// context makes placement depend on whether the caller has already
// translated to the layer center (native preview does; other callers
// do not), which caused the effect to shift and clip in Program
// Preview.
const offscreen = CanvasDevice.acquire(unscaledOffW, unscaledOffH);
const offCtx = offscreen.getContext("2d", { alpha: true }) as OffscreenCanvasRenderingContext2D | null;
if (offCtx) {
offCtx.setTransform(1, 0, 0, 1, 0, 0);
offCtx.clearRect(0, 0, unscaledOffW, unscaledOffH);
engineEvaluateScene(canonicalScene, layer.time ?? 0, offCtx as unknown as CanvasRenderingContext2D);
ctx.drawImage(
offscreen,
0,
0,
unscaledOffW,
unscaledOffH,
-width / 2 - effectPaddingX,
-height / 2 - effectPaddingY,
offW,
offH,
);
}
Promise.resolve().then(() => CanvasDevice.release(offscreen));
return;
}

Expand Down Expand Up @@ -336,6 +393,13 @@ export async function rasterizeTextLayer(ctx: CanvasRenderingContext2D | Offscre
}

const sceneDoc = textEffectConfigToScene(engineConfig);
traceTextRenderScene(sceneDoc, {
path: "program-preview",
assetId: layer.styleId,
revisionId: (effectDef as any)?.revisionId,
contentHash: (effectDef as any)?.contentHash,
time: layer.time ?? 0,
});

// Acquire canvas context from the unified CanvasDevice pool
// CRITICAL: Use UNSCALED dimensions for text rendering to ensure consistent layout
Expand Down
112 changes: 112 additions & 0 deletions src/core/render/textRenderTrace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
export interface TextRenderTraceLayer {
id?: string;
type?: string;
enabled?: boolean;
opacity?: number;
params?: Record<string, unknown>;
}

export interface TextRenderTraceScene {
schemaVersion?: number;
revision?: {
assetId?: string;
revisionId?: string;
contentHash?: string;
rendererVersion?: string;
};
canvas?: {
width?: number;
height?: number;
background?: string;
backgroundConfig?: unknown;
};
text?: {
content?: string;
fontFamily?: string;
fontSize?: number;
fontWeight?: number;
};
compositor?: unknown;
effectLayers?: TextRenderTraceLayer[];
legacyConfig?: unknown;
}

export interface TextRenderTraceContext {
path: "source-preview" | "program-preview" | "export";
assetId?: string;
category?: string;
revisionId?: string;
contentHash?: string;
time?: number;
}

const loggedKeys = new Set<string>();

function isTraceEnabled(): boolean {
return import.meta.env.DEV || import.meta.env.VITE_CLYPRA_TEXT_RENDER_TRACE === "1";
}

function layerState(layer: TextRenderTraceLayer) {
const enabled = layer.enabled === true;
const opacity = typeof layer.opacity === "number" ? layer.opacity : 1;
return {
id: layer.id,
type: layer.type,
enabled,
opacity,
active: enabled && opacity > 0,
params: layer.params ?? {},
};
}

/**
* Logs the resolved text scene once per asset/revision/render path in dev.
* This is intentionally state-focused so it exposes accidental activation of
* panel/glow/shadow layers without flooding the console on every frame.
*/
export function traceTextRenderScene(
scene: TextRenderTraceScene,
context: TextRenderTraceContext,
): void {
if (!isTraceEnabled()) return;

const revision = context.revisionId || scene.revision?.revisionId || "latest";
const asset = context.assetId || scene.revision?.assetId || "anonymous";
const key = `${context.path}:${asset}:${revision}:${context.contentHash || scene.revision?.contentHash || ""}`;
if (loggedKeys.has(key)) return;
loggedKeys.add(key);

const layers = (scene.effectLayers ?? []).map(layerState);
const activeLayers = layers.filter((layer) => layer.active);
const activePanel = activeLayers.find((layer) => layer.type === "panel");
const activeGlow = activeLayers.filter((layer) => layer.type === "glow");

console.groupCollapsed(`[Clypra:text-render] ${context.path} ${asset}@${revision}`);
console.log("source", {
assetId: asset,
category: context.category,
revisionId: revision,
contentHash: context.contentHash || scene.revision?.contentHash,
rendererVersion: scene.revision?.rendererVersion,
schemaVersion: scene.schemaVersion,
time: context.time,
});
console.log("canvas", scene.canvas);
console.log("text", scene.text);
console.log("compositor", scene.compositor);
console.table(layers);
console.log("active contributors", activeLayers.map(({ id, type, opacity, params }) => ({ id, type, opacity, params })));
console.log("legacy compatibility fields", scene.legacyConfig ?? null);

if (activePanel) {
console.warn("[Clypra:text-render] Active panel/background plate", activePanel);
}
if (activeGlow.length > 0) {
console.warn("[Clypra:text-render] Active glow contributors", activeGlow);
}
console.groupEnd();
}

export function resetTextRenderTrace(): void {
loggedKeys.clear();
}
Loading
Loading