Skip to content
Open

wip #29

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/lib/ai/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ export function mutateCreateLayer(
layer.contentOffset = input.contentOffset;
}

// Set enter/exit transitions if provided
if (input.enterTransition) {
const preset = getPresetById(input.enterTransition.presetId);
if (preset) {
layer.enterTransition = {
presetId: input.enterTransition.presetId,
duration: input.enterTransition.duration
};
}
}
if (input.exitTransition) {
const preset = getPresetById(input.exitTransition.presetId);
if (preset) {
layer.exitTransition = {
presetId: input.exitTransition.presetId,
duration: input.exitTransition.duration
};
}
}
Comment on lines +134 to +152
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent failure for invalid presetId — AI receives success: true even if transition not applied

When getPresetById(input.enterTransition.presetId) returns undefined (invalid ID), the transition is silently dropped and mutateCreateLayer still returns success: true. The AI has no signal that the transition was rejected, which could cause it to assume the transition is in effect or repeatedly submit the same invalid preset ID.

The same pattern repeats for exitTransition (lines 144–152) and in mutateEditLayer (lines 278–284, 291–297).

🛡️ Proposed fix — surface invalid preset ID in the output message
     if (input.enterTransition) {
       const preset = getPresetById(input.enterTransition.presetId);
       if (preset) {
         layer.enterTransition = {
           presetId: input.enterTransition.presetId,
           duration: input.enterTransition.duration
         };
+      } else {
+        console.warn(`[mutateCreateLayer] Unknown enterTransition presetId: "${input.enterTransition.presetId}"`);
       }
     }

For mutateEditLayer, include the warning in the returned message so the AI can self-correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ai/mutations.ts` around lines 134 - 152, When getPresetById(...)
returns undefined for enterTransition or exitTransition inside mutateCreateLayer
and mutateEditLayer, don't silently drop it — collect a warning (e.g., "invalid
presetId for enterTransition: <id>") and append or include these warnings in the
mutation's returned message/response so the AI sees the rejection; modify the
blocks that set layer.enterTransition / layer.exitTransition (where
getPresetById is called) to push warnings into a local warnings array and ensure
mutateCreateLayer and mutateEditLayer include that warnings text in their final
return payload/message (and include references to getPresetById,
layer.enterTransition, layer.exitTransition, mutateCreateLayer,
mutateEditLayer).


// Mutate project
ctx.project.layers.push(layer);

Expand Down Expand Up @@ -252,6 +272,34 @@ export function mutateEditLayer(ctx: MutationContext, input: EditLayerInput): Ed
layer.contentOffset = Math.max(0, input.contentOffset);
}

// Update enter/exit transitions
if (input.enterTransition !== undefined) {
if (input.enterTransition) {
const preset = getPresetById(input.enterTransition.presetId);
if (preset) {
layer.enterTransition = {
presetId: input.enterTransition.presetId,
duration: input.enterTransition.duration
};
}
} else {
layer.enterTransition = undefined;
}
}
if (input.exitTransition !== undefined) {
if (input.exitTransition) {
const preset = getPresetById(input.exitTransition.presetId);
if (preset) {
layer.exitTransition = {
presetId: input.exitTransition.presetId,
duration: input.exitTransition.duration
};
}
} else {
layer.exitTransition = undefined;
}
}
Comment on lines +276 to +301
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n 'EditLayerInputSchema|LayerTransitionSchema' --type=ts -B 2 -A 5

Repository: epavanello/devmotion

Length of output: 3265


🏁 Script executed:

sed -n '198,242p' src/lib/ai/schemas.ts

Repository: epavanello/devmotion

Length of output: 1486


🏁 Script executed:

rg -n 'LayerTransitionFieldSchema' --type=ts -B 2 -A 5

Repository: epavanello/devmotion

Length of output: 2155


The else branches (lines 285-287, 298-300) are dead code — the schema does not allow null values

The description comment states "set to null to remove", but LayerTransitionFieldSchema.optional() only allows undefined or the object type, not null. The else branch can never execute since input.enterTransition cannot be falsy while also passing validation.

To support clearing transitions via null, add .nullable() to the schema:

enterTransition: LayerTransitionFieldSchema.nullable().optional().describe(...)

Alternatively, remove the dead else branch and rely on the if (input.enterTransition !== undefined) check to determine intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ai/mutations.ts` around lines 276 - 301, The conditional else
branches that set layer.enterTransition/exitTransition = undefined are dead
because input.enterTransition and input.exitTransition cannot be null/false per
the current LayerTransitionFieldSchema; either make the schema nullable so
callers can send null to clear transitions (update LayerTransitionFieldSchema to
.nullable().optional() for enterTransition/exitTransition) or remove the
unreachable else blocks and only handle the presence check (if
(input.enterTransition !== undefined) / if (input.exitTransition !==
undefined)), keeping the preset validation via getPresetById and assignment to
layer.enterTransition/layer.exitTransition as currently written.


return {
success: true,
layerId: resolvedId,
Expand Down
43 changes: 37 additions & 6 deletions src/lib/ai/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ const TimingFieldsSchema = z.object({
.describe('Start offset for trimming media content (seconds)')
});

// ============================================
// Layer Transition (enter/exit preset)
// ============================================

const LayerTransitionFieldSchema = z
.object({
presetId: AnimationPresetIdSchema.describe('Animation preset ID to apply as transition'),
duration: z.number().positive().describe('Transition duration in seconds (typically 0.3-0.8)')
})
.describe(
'Automatic animation applied at layer enter/exit. Position presets are relative offsets from the layer base position. Scale/opacity are factors (0→1 means invisible→visible).'
);

// ============================================
// Layer Type + Props Union
// ============================================
Expand Down Expand Up @@ -123,9 +136,17 @@ export const CreateLayerInputSchema = z
// Layer type and properties
layer: LayerTypePropsUnion,

// Animation (preset OR custom keyframes)
// Transitions (automatic enter/exit animations, no keyframes created)
enterTransition: LayerTransitionFieldSchema.optional().describe(
'Auto-play animation when layer enters (e.g., fade-in, slide-in-left). Applied as offset/factor on base transform.'
),
exitTransition: LayerTransitionFieldSchema.optional().describe(
'Auto-play animation when layer exits (e.g., fade-out, slide-out-right). Applied as offset/factor on base transform.'
),

// Animation (preset OR custom keyframes - baked as keyframes)
animation: CreateLayerAnimationSchema.optional().describe(
'Animation: preset OR custom keyframes'
'Animation as keyframes: preset (baked at startTime) OR custom keyframes'
)
})
.refine(
Expand Down Expand Up @@ -199,7 +220,15 @@ export const EditLayerInputSchema = z
.describe('Layer-specific properties to update (merged with existing)'),

// Timing fields
...TimingFieldsSchema.shape
...TimingFieldsSchema.shape,

// Transitions (automatic enter/exit animations)
enterTransition: LayerTransitionFieldSchema.optional().describe(
'Set enter transition preset. Omit to keep existing, set to null to remove.'
),
exitTransition: LayerTransitionFieldSchema.optional().describe(
'Set exit transition preset. Omit to keep existing, set to null to remove.'
)
Comment on lines +226 to +231
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the schema definitions in schemas.ts
cat -n src/lib/ai/schemas.ts | sed -n '50,70p'

Repository: epavanello/devmotion

Length of output: 987


🏁 Script executed:

# Check the CreateLayerInputSchema definition
cat -n src/lib/ai/schemas.ts | sed -n '138,155p'

Repository: epavanello/devmotion

Length of output: 1031


🏁 Script executed:

# Check the EditLayerInputSchema definition around lines 220-235
cat -n src/lib/ai/schemas.ts | sed -n '220,240p'

Repository: epavanello/devmotion

Length of output: 976


🏁 Script executed:

# Search for how enterTransition/exitTransition are handled in mutations
rg -n 'enterTransition|exitTransition' --type=ts src/lib -B3 -A3

Repository: epavanello/devmotion

Length of output: 12983


Change .optional() to .nullish() to match description and mutations.ts implementation.

The EditLayerInputSchema description tells the AI to "set to null to remove," but .optional() only accepts undefined, not null. Meanwhile, the mutations.ts code explicitly handles falsy values after checking !== undefined (lines 276-301), expecting to receive null to clear the field.

Use .nullish() to accept both undefined (field omitted = don't change) and null (explicit = remove):

Fix: Use .nullish()
-    enterTransition: LayerTransitionFieldSchema.optional().describe(
+    enterTransition: LayerTransitionFieldSchema.nullish().describe(
       'Set enter transition preset. Omit to keep existing, set to null to remove.'
     ),
-    exitTransition: LayerTransitionFieldSchema.optional().describe(
+    exitTransition: LayerTransitionFieldSchema.nullish().describe(
       'Set exit transition preset. Omit to keep existing, set to null to remove.'
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
enterTransition: LayerTransitionFieldSchema.optional().describe(
'Set enter transition preset. Omit to keep existing, set to null to remove.'
),
exitTransition: LayerTransitionFieldSchema.optional().describe(
'Set exit transition preset. Omit to keep existing, set to null to remove.'
)
enterTransition: LayerTransitionFieldSchema.nullish().describe(
'Set enter transition preset. Omit to keep existing, set to null to remove.'
),
exitTransition: LayerTransitionFieldSchema.nullish().describe(
'Set exit transition preset. Omit to keep existing, set to null to remove.'
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ai/schemas.ts` around lines 226 - 231, The schema fields
enterTransition and exitTransition on EditLayerInputSchema currently use
LayerTransitionFieldSchema.optional(), but the description and the mutations
handler expect null to explicitly remove a value; change both enterTransition
and exitTransition to LayerTransitionFieldSchema.nullish() so the schema accepts
undefined (omit = keep) and null (explicit remove) and aligns with the mutation
logic that checks for !== undefined and treats falsy/null as removal.

})
.refine(
(data) => {
Expand Down Expand Up @@ -295,11 +324,12 @@ export const animationTools = {
- Position, rotation, scale, anchor point
- Layer-specific props (text content, colors, sizes, etc.)
- Style (opacity, blur, filters, drop shadow)
- Animation via preset OR custom keyframes
- Enter/exit transitions (automatic preset animations at layer boundaries)
- Animation via preset (baked as keyframes) OR custom keyframes
- Timing (enter/exit times, content duration/offset)

Example: Create a text layer with animation:
{ "layer": { "type": "text", "props": { "content": "Hello World", "fontSize": 48, "fill": "#ffffff" } }, "transform": { "position": { "x": 0, "y": -200 } }, "animation": { "preset": { "id": "fade-in", "startTime": 0, "duration": 0.5 } } }`,
Example with enter transition:
{ "layer": { "type": "text", "props": { "content": "Hello", "fontSize": 48, "fill": "#fff" } }, "transform": { "position": { "x": 0, "y": -200 } }, "enterTransition": { "presetId": "fade-in", "duration": 0.5 }, "exitTransition": { "presetId": "fade-out", "duration": 0.3 } }`,
inputSchema: CreateLayerInputSchema
}),

Expand All @@ -308,6 +338,7 @@ Example: Create a text layer with animation:
- Provide only the fields you want to change
- transform/style sections replace entire object if provided
- props are merged with existing props
- enterTransition/exitTransition: set automatic enter/exit animations
- Use layer ID from create_layer response or layer name`,
inputSchema: EditLayerInputSchema
}),
Expand Down
45 changes: 41 additions & 4 deletions src/lib/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
*
* Tools: create_layer, edit_layer, remove_layer, configure_project, group_layers, ungroup_layers
* Animation: can use preset AND/OR custom keyframes in create_layer.
* Transitions: can set enterTransition/exitTransition on layers via edit_layer.
*/
import type { Project } from '$lib/types/animation';
import { projectDataSchema } from '$lib/schemas/animation';
import { getPresetsSummaryForAI } from '$lib/engine/presets';
import goodExampleRaw from '$lib/good-example.json';

const exampleProject = projectDataSchema.parse(goodExampleRaw);
Expand All @@ -29,12 +31,47 @@ Steps: present plan (if full video) → execute tool calls → conclude with a f

## Tools

- **create_layer**: Layer type + props + transform + style + animation + timing
- **edit_layer**: Modify existing layer (provide fields to change)
- **create_layer**: Layer type + props + transform + style + animation + timing + enterTransition/exitTransition
- **edit_layer**: Modify existing layer (provide fields to change, including enterTransition/exitTransition)
- **remove_layer**: Delete layer by ID or name
- **configure_project**: Project settings (name, dimensions, duration, background)
- **group_layers** / **ungroup_layers**: Group/ungroup layers

## Animation System

Two complementary ways to animate layers:

### 1. Transitions (enterTransition / exitTransition)
Automatic enter/exit animations applied at runtime (no keyframes created).
Set via create_layer or edit_layer. Position presets are relative offsets from the layer's base position.
Scale and opacity presets are applied as factors (e.g., scale 0→1 means from invisible to full size).
When the transition finishes, the layer returns to its base values.

\`\`\`json
{ "enterTransition": { "presetId": "fade-in", "duration": 0.5 } }
{ "exitTransition": { "presetId": "slide-out-left", "duration": 0.3 } }
\`\`\`

### 2. Keyframe Presets (animation.preset in create_layer)
Bakes preset keyframes onto the layer at a specific time. Good for emphasis effects or custom timing.
Position values are added as offsets to the layer's current base position.

\`\`\`json
{ "animation": { "preset": { "id": "pulse", "startTime": 1.0, "duration": 0.5 } } }
\`\`\`

### 3. Custom Keyframes (animation.keyframes in create_layer)
Full control over individual property animations. Can be combined with presets.

### When to use which:
- **Transitions**: Best for entrance/exit effects. No keyframes cluttering the timeline. Automatically tied to layer enter/exit time.
- **Keyframe presets**: Best for emphasis effects (pulse, shake, bounce) at specific moments.
- **Custom keyframes**: Best for unique, hand-crafted animations.

## Available Presets

${getPresetsSummaryForAI()}

## Graphic Style - MANDATORY DIRECTIVES

### Backgrounds
Expand Down Expand Up @@ -106,8 +143,8 @@ ${JSON.stringify(exampleProject)}

When the user gives no specific animation directions, apply at minimum:

- **Entrances/Exits**: Fade in from opacity:0 + slight scale (0.95→1) + blur (filter.blur 8→0). Add a gentle slide (20-40px) from a direction. Reverse for exits.
- **Text layers**: At minimum, appear from opacity:0 with a subtle slide-up on Y (~20px). Titles can add scale+blur for more impact.
- **Entrances/Exits**: Use enterTransition/exitTransition with appropriate presets (fade-in, slide-in-*, scale-in for entrances; fade-out, slide-out-*, scale-out for exits). Default duration 0.3-0.6s.
- **Text layers**: At minimum, use enterTransition with fade-in or slide-in-bottom (duration 0.4s). Titles can use pop or bounce-in for more impact.
- **Flat backgrounds**: If a background feels too plain, add 1-2 decorative circle shapes (≈200x200, high blur ≈150, low opacity ≈0.15-0.25) as soft ambient blobs with gentle position drift.

These are sensible defaults — override freely when the user provides specific creative direction.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ai/model-selector.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, Lock } from '@lucide/svelte';
import { ChevronDown } from '@lucide/svelte';
import { AI_MODELS, getModel } from '$lib/ai/models';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { getUser } from '$lib/functions/auth.remote';
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/editor/canvas/layers-renderer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
}
// Otherwise compute using shared rendering functions
return {
transform: getLayerTransform(layer, currentTime),
style: getLayerStyle(layer, currentTime),
transform: getLayerTransform(layer, currentTime, duration),
style: getLayerStyle(layer, currentTime, duration),
customProps: getLayerProps(layer, currentTime)
};
}
Expand Down
16 changes: 4 additions & 12 deletions src/lib/components/editor/panels/properties-panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
import type { Layer } from '$lib/schemas/animation';
import { getDefaultInterpolationForProperty } from '$lib/utils/interpolation-utils';
import { defaultLayerStyle, defaultTransform } from '$lib/schemas/base';
import { Clock, Move, Palette, Layers, Sparkles, Boxes } from '@lucide/svelte';
import { Clock, Move, Palette, Layers, Boxes } from '@lucide/svelte';

// Property group components
import TimeRangeGroup from './properties/groups/time-range-group.svelte';
import TransformGroup from './properties/groups/transform-group.svelte';
import StyleGroup from './properties/groups/style-group.svelte';
import AnimationPresetsGroup from './properties/groups/animation-presets-group.svelte';
import KeyframesGroup from './properties/groups/keyframes-group.svelte';
import LayerPropertiesGroup from './properties/groups/layer-properties-group.svelte';
import AnimationPresetsGroup from './properties/groups/animation-presets-group.svelte';

const editorState = $derived(getEditorState());
const projectStore = $derived(editorState.project);
Expand Down Expand Up @@ -334,11 +334,11 @@

<Separator />

<!-- Time Range -->
<!-- Time Range & Transitions -->
<PropertiesGroup icon={Clock}>
{#snippet label()}
<div class="flex w-full items-center justify-between">
<Label class="font-semibold">Time Range</Label>
<Label class="font-semibold">Time & Transitions</Label>
{#if selectedLayer.contentDuration !== undefined}
{@const contentDuration = selectedLayer.contentDuration}
{@const contentOffset = selectedLayer.contentOffset ?? 0}
Expand All @@ -350,14 +350,6 @@
</div>
{/snippet}
<TimeRangeGroup layer={selectedLayer} />
</PropertiesGroup>
{/if}

{#if !isProjectSettings}
<Separator />

<!-- Animation Presets -->
<PropertiesGroup label="Animation Presets" icon={Sparkles}>
<AnimationPresetsGroup layer={selectedLayer} />
</PropertiesGroup>

Expand Down
Loading