Browse Source

fix json patch (#78)

Chris Tate 5 tháng trước cách đây
mục cha
commit
1eb6212dd7

+ 19 - 5
apps/web/app/(main)/docs/api/core/page.tsx

@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
       </p>
       </p>
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
 
 
-const jsonl = \`{"op":"set","path":"/root","value":{}}
-{"op":"set","path":"/root/type","value":"Card"}\`;
+const jsonl = \`{"op":"add","path":"/root","value":{}}
+{"op":"add","path":"/root/type","value":"Card"}\`;
 
 
 const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
 const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
 
 
@@ -285,22 +285,36 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
 } from '@json-render/core';
 } from '@json-render/core';
 
 
 // Parse a single line
 // Parse a single line
-const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
+const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
 
 
 // Apply patch to object (mutates in place)
 // Apply patch to object (mutates in place)
 const obj = {};
 const obj = {};
 applySpecStreamPatch(obj, patch);`}</Code>
 applySpecStreamPatch(obj, patch);`}</Code>
 
 
       <h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
       <h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Fully compliant with{" "}
+        <a
+          href="https://datatracker.ietf.org/doc/html/rfc6902"
+          className="underline"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          RFC 6902
+        </a>
+        :
+      </p>
       <Code lang="typescript">{`interface SpecStreamLine {
       <Code lang="typescript">{`interface SpecStreamLine {
-  op: 'set' | 'add' | 'replace' | 'remove';
+  op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
   path: string;
   path: string;
-  value?: unknown;
+  value?: unknown;  // Required for add, replace, test
+  from?: string;    // Required for move, copy
 }
 }
 
 
 interface SpecStreamCompiler<T> {
 interface SpecStreamCompiler<T> {
   push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
   push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
   getResult(): T;
   getResult(): T;
+  getPatches(): SpecStreamLine[];
   reset(): void;
   reset(): void;
 }`}</Code>
 }`}</Code>
 
 

+ 37 - 16
apps/web/app/(main)/docs/streaming/page.tsx

@@ -18,10 +18,10 @@ export default function StreamingPage() {
         format where each line is a JSON patch operation that progressively
         format where each line is a JSON patch operation that progressively
         builds your spec:
         builds your spec:
       </p>
       </p>
-      <Code lang="json">{`{"op":"set","path":"/root","value":"root"}
-{"op":"set","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
-{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
-{"op":"set","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
+      <Code lang="json">{`{"op":"add","path":"/root","value":"root"}
+{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
+{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
+{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
 
 
       <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
       <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
       <p className="text-sm text-muted-foreground mb-4">
       <p className="text-sm text-muted-foreground mb-4">
@@ -43,26 +43,47 @@ function App() {
   });
   });
 }`}</Code>
 }`}</Code>
 
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Patch Operations (RFC 6902)
+      </h2>
       <p className="text-sm text-muted-foreground mb-4">
       <p className="text-sm text-muted-foreground mb-4">
-        Supported operations:
+        SpecStream uses{" "}
+        <a
+          href="https://datatracker.ietf.org/doc/html/rfc6902"
+          className="underline"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          RFC 6902 JSON Patch
+        </a>{" "}
+        operations:
       </p>
       </p>
       <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
       <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
         <li>
         <li>
-          <code className="text-foreground">set</code> — Set the value at a path
-          (creates if needed)
+          <code className="text-foreground">add</code> — Add a value at a path
+          (creates or replaces for objects, inserts for arrays)
+        </li>
+        <li>
+          <code className="text-foreground">remove</code> — Remove the value at
+          a path
+        </li>
+        <li>
+          <code className="text-foreground">replace</code> — Replace an existing
+          value at a path
         </li>
         </li>
         <li>
         <li>
-          <code className="text-foreground">add</code> — Add to an array at a
-          path
+          <code className="text-foreground">move</code> — Move a value from one
+          path to another (requires{" "}
+          <code className="text-foreground">from</code>)
         </li>
         </li>
         <li>
         <li>
-          <code className="text-foreground">replace</code> — Replace value at a
-          path
+          <code className="text-foreground">copy</code> — Copy a value from one
+          path to another (requires{" "}
+          <code className="text-foreground">from</code>)
         </li>
         </li>
         <li>
         <li>
-          <code className="text-foreground">remove</code> — Remove value at a
-          path
+          <code className="text-foreground">test</code> — Assert that a value at
+          a path equals the given value
         </li>
         </li>
       </ul>
       </ul>
 
 
@@ -170,8 +191,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
       </p>
       </p>
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
 
 
-const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
-{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
+const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}}
+{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
 
 
 const spec = compileSpecStream<MySpec>(jsonl);
 const spec = compileSpecStream<MySpec>(jsonl);
 // { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
 // { root: { type: "Card", props: { title: "Hello" } } }`}</Code>

+ 3 - 2
apps/web/app/api/generate/route.ts

@@ -62,8 +62,9 @@ USER REQUEST: ${sanitizedPrompt}
 
 
 IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
 IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
 - To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
 - To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
-- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
-- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
+- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
+- To remove an element: {"op":"remove","path":"/elements/old-key"}
+- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
 - To add children: update the parent element with new children array
 - To add children: update the parent element with new children array
 
 
 DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
 DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;

+ 1 - 1
apps/web/components/demo.tsx

@@ -35,7 +35,7 @@ const SIMULATION_STAGES: SimulationStage[] = [
         },
         },
       },
       },
     },
     },
-    stream: '{"op":"set","path":"/root","value":"card"}',
+    stream: '{"op":"add","path":"/root","value":"card"}',
   },
   },
   {
   {
     tree: {
     tree: {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
examples/remotion/tsconfig.tsbuildinfo


+ 8 - 6
packages/core/README.md

@@ -116,14 +116,16 @@ while (streaming) {
 const finalSpec = compiler.getResult();
 const finalSpec = compiler.getResult();
 ```
 ```
 
 
-SpecStream format (each line is a JSON patch):
+SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
 
 
 ```jsonl
 ```jsonl
-{"op":"set","path":"/root/type","value":"Card"}
-{"op":"set","path":"/root/props","value":{"title":"Hello"}}
-{"op":"set","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
+{"op":"add","path":"/root/type","value":"Card"}
+{"op":"add","path":"/root/props","value":{"title":"Hello"}}
+{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
 ```
 ```
 
 
+All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
+
 ### Low-Level Utilities
 ### Low-Level Utilities
 
 
 ```typescript
 ```typescript
@@ -134,8 +136,8 @@ import {
 } from "@json-render/core";
 } from "@json-render/core";
 
 
 // Parse a single line
 // Parse a single line
-const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
-// { op: "set", path: "/root", value: {} }
+const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
+// { op: "add", path: "/root", value: {} }
 
 
 // Apply a patch to an object
 // Apply a patch to an object
 const obj = {};
 const obj = {};

+ 4 - 4
packages/core/src/catalog.ts

@@ -493,8 +493,8 @@ export function generateSystemPrompt<
   }
   }
 
 
   // Output format
   // Output format
-  lines.push("OUTPUT FORMAT (JSONL):");
-  lines.push('{"op":"set","path":"/root","value":"element-key"}');
+  lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
+  lines.push('{"op":"add","path":"/root","value":"element-key"}');
   lines.push(
   lines.push(
     '{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
     '{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
   );
   );
@@ -504,8 +504,8 @@ export function generateSystemPrompt<
   // Rules
   // Rules
   lines.push("RULES:");
   lines.push("RULES:");
   const baseRules = [
   const baseRules = [
-    "First line sets /root to root element key",
-    "Add elements with /elements/{key}",
+    'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
+    'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
     "Remove elements with op:remove - also update the parent's children array to exclude the removed key",
     "Remove elements with op:remove - also update the parent's children array to exclude the removed key",
     "Children array contains string keys, not objects",
     "Children array contains string keys, not objects",
     "Parent first, then children",
     "Parent first, then children",

+ 3 - 1
packages/core/src/index.ts

@@ -28,8 +28,10 @@ export {
   resolveDynamicValue,
   resolveDynamicValue,
   getByPath,
   getByPath,
   setByPath,
   setByPath,
+  addByPath,
+  removeByPath,
   findFormValue,
   findFormValue,
-  // SpecStream - streaming format for building specs
+  // SpecStream - streaming format for building specs (RFC 6902)
   parseSpecStreamLine,
   parseSpecStreamLine,
   applySpecStreamPatch,
   applySpecStreamPatch,
   compileSpecStream,
   compileSpecStream,

+ 6 - 6
packages/core/src/schema.ts

@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
   lines.push("");
   lines.push("");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("");
   lines.push("");
-  lines.push(`{"op":"set","path":"/root","value":"card-1"}
-{"op":"set","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
-{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
-{"op":"set","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
+  lines.push(`{"op":"add","path":"/root","value":"card-1"}
+{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
+{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
+{"op":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
   lines.push("");
   lines.push("");
 
 
   // Components section
   // Components section
@@ -591,8 +591,8 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
   lines.push("RULES:");
   lines.push("RULES:");
   const baseRules = [
   const baseRules = [
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
-    'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
-    'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
+    'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}',
+    'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
     "ONLY use components listed above",
     "ONLY use components listed above",
     "Each element value needs: type, props, children (array of child keys)",
     "Each element value needs: type, props, children (array of child keys)",
     "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
     "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",

+ 435 - 1
packages/core/src/types.test.ts

@@ -1,5 +1,14 @@
 import { describe, it, expect } from "vitest";
 import { describe, it, expect } from "vitest";
-import { resolveDynamicValue, getByPath, setByPath } from "./types";
+import {
+  resolveDynamicValue,
+  getByPath,
+  setByPath,
+  addByPath,
+  removeByPath,
+  applySpecStreamPatch,
+  compileSpecStream,
+  createSpecStreamCompiler,
+} from "./types";
 
 
 describe("getByPath", () => {
 describe("getByPath", () => {
   it("gets nested values with JSON pointer paths", () => {
   it("gets nested values with JSON pointer paths", () => {
@@ -123,3 +132,428 @@ describe("resolveDynamicValue", () => {
     ).toBeUndefined();
     ).toBeUndefined();
   });
   });
 });
 });
+
+// =============================================================================
+// JSON Pointer (RFC 6901) escaping
+// =============================================================================
+
+describe("JSON Pointer escaping (RFC 6901)", () => {
+  it("getByPath unescapes ~1 to /", () => {
+    const data = { "a/b": { c: 42 } };
+    expect(getByPath(data, "/a~1b/c")).toBe(42);
+  });
+
+  it("getByPath unescapes ~0 to ~", () => {
+    const data = { "a~b": 99 };
+    expect(getByPath(data, "/a~0b")).toBe(99);
+  });
+
+  it("getByPath handles combined ~0 and ~1 escaping", () => {
+    const data = { "a~/b": "found" };
+    expect(getByPath(data, "/a~0~1b")).toBe("found");
+  });
+
+  it("setByPath unescapes ~1 to / in keys", () => {
+    const data: Record<string, unknown> = {};
+    setByPath(data, "/a~1b", "value");
+    expect(data["a/b"]).toBe("value");
+  });
+
+  it("setByPath unescapes ~0 to ~ in keys", () => {
+    const data: Record<string, unknown> = {};
+    setByPath(data, "/a~0b", "value");
+    expect(data["a~b"]).toBe("value");
+  });
+
+  it("getByPath reads array elements properly", () => {
+    const data = { items: [10, 20, 30] };
+    expect(getByPath(data, "/items/0")).toBe(10);
+    expect(getByPath(data, "/items/2")).toBe(30);
+  });
+});
+
+// =============================================================================
+// addByPath (RFC 6902 "add" semantics)
+// =============================================================================
+
+describe("addByPath", () => {
+  it("adds a property to an object", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    addByPath(data, "/b", 2);
+    expect(data.b).toBe(2);
+  });
+
+  it("replaces an existing object property (add semantics)", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    addByPath(data, "/a", 99);
+    expect(data.a).toBe(99);
+  });
+
+  it("inserts into an array at a specific index", () => {
+    const data: Record<string, unknown> = { arr: [1, 2, 3] };
+    addByPath(data, "/arr/1", 99);
+    expect(data.arr).toEqual([1, 99, 2, 3]);
+  });
+
+  it("appends to an array with - (end-of-array)", () => {
+    const data: Record<string, unknown> = { arr: [1, 2] };
+    addByPath(data, "/arr/-", 3);
+    expect(data.arr).toEqual([1, 2, 3]);
+  });
+
+  it("inserts at index 0 of an array", () => {
+    const data: Record<string, unknown> = { arr: ["b", "c"] };
+    addByPath(data, "/arr/0", "a");
+    expect(data.arr).toEqual(["a", "b", "c"]);
+  });
+
+  it("creates intermediate objects", () => {
+    const data: Record<string, unknown> = {};
+    addByPath(data, "/x/y/z", "deep");
+    expect(
+      ((data.x as Record<string, unknown>).y as Record<string, unknown>).z,
+    ).toBe("deep");
+  });
+});
+
+// =============================================================================
+// removeByPath (RFC 6902 "remove" semantics)
+// =============================================================================
+
+describe("removeByPath", () => {
+  it("deletes an object property", () => {
+    const data: Record<string, unknown> = { a: 1, b: 2 };
+    removeByPath(data, "/a");
+    expect(data).toEqual({ b: 2 });
+    expect("a" in data).toBe(false);
+  });
+
+  it("removes an element from an array by index", () => {
+    const data: Record<string, unknown> = { arr: [1, 2, 3] };
+    removeByPath(data, "/arr/1");
+    expect(data.arr).toEqual([1, 3]);
+  });
+
+  it("removes nested properties", () => {
+    const data: Record<string, unknown> = { user: { name: "John", age: 30 } };
+    removeByPath(data, "/user/age");
+    expect(data.user).toEqual({ name: "John" });
+  });
+
+  it("is a no-op for non-existent paths", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    removeByPath(data, "/b/c/d");
+    expect(data).toEqual({ a: 1 });
+  });
+});
+
+// =============================================================================
+// applySpecStreamPatch - RFC 6902 operations
+// =============================================================================
+
+describe("applySpecStreamPatch", () => {
+  describe("add operation", () => {
+    it("adds a new object property", () => {
+      const obj: Record<string, unknown> = {};
+      applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" });
+      expect(obj.name).toBe("Alice");
+    });
+
+    it("adds nested properties creating intermediates", () => {
+      const obj: Record<string, unknown> = {};
+      applySpecStreamPatch(obj, {
+        op: "add",
+        path: "/user/name",
+        value: "Bob",
+      });
+      expect((obj.user as Record<string, unknown>).name).toBe("Bob");
+    });
+
+    it("inserts into array at index", () => {
+      const obj: Record<string, unknown> = { items: [1, 3] };
+      applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 });
+      expect(obj.items).toEqual([1, 2, 3]);
+    });
+
+    it("appends to array with -", () => {
+      const obj: Record<string, unknown> = { items: [1, 2] };
+      applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 });
+      expect(obj.items).toEqual([1, 2, 3]);
+    });
+  });
+
+  describe("remove operation", () => {
+    it("removes an object property", () => {
+      const obj: Record<string, unknown> = { name: "Alice", age: 30 };
+      applySpecStreamPatch(obj, { op: "remove", path: "/age" });
+      expect(obj).toEqual({ name: "Alice" });
+      expect("age" in obj).toBe(false);
+    });
+
+    it("removes from array by index", () => {
+      const obj: Record<string, unknown> = { items: ["a", "b", "c"] };
+      applySpecStreamPatch(obj, { op: "remove", path: "/items/1" });
+      expect(obj.items).toEqual(["a", "c"]);
+    });
+  });
+
+  describe("replace operation", () => {
+    it("replaces an existing value", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      applySpecStreamPatch(obj, {
+        op: "replace",
+        path: "/name",
+        value: "Bob",
+      });
+      expect(obj.name).toBe("Bob");
+    });
+
+    it("replaces nested values", () => {
+      const obj: Record<string, unknown> = { user: { name: "Alice" } };
+      applySpecStreamPatch(obj, {
+        op: "replace",
+        path: "/user/name",
+        value: "Bob",
+      });
+      expect((obj.user as Record<string, unknown>).name).toBe("Bob");
+    });
+  });
+
+  describe("move operation", () => {
+    it("moves a value from one path to another", () => {
+      const obj: Record<string, unknown> = { a: 1, b: 2 };
+      applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" });
+      expect(obj).toEqual({ b: 2, c: 1 });
+      expect("a" in obj).toBe(false);
+    });
+
+    it("moves nested values", () => {
+      const obj: Record<string, unknown> = {
+        source: { val: 42 },
+        target: {},
+      };
+      applySpecStreamPatch(obj, {
+        op: "move",
+        path: "/target/val",
+        from: "/source/val",
+      });
+      expect((obj.target as Record<string, unknown>).val).toBe(42);
+      expect("val" in (obj.source as Record<string, unknown>)).toBe(false);
+    });
+
+    it("is a no-op if from is missing", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "move", path: "/b" });
+      expect(obj).toEqual({ a: 1 });
+    });
+  });
+
+  describe("copy operation", () => {
+    it("copies a value from one path to another", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" });
+      expect(obj).toEqual({ a: 1, b: 1 });
+    });
+
+    it("copies complex values", () => {
+      const obj: Record<string, unknown> = {
+        source: { nested: { value: 42 } },
+      };
+      applySpecStreamPatch(obj, {
+        op: "copy",
+        path: "/dest",
+        from: "/source/nested",
+      });
+      expect(obj.dest).toEqual({ value: 42 });
+      // Source should still exist
+      expect((obj.source as Record<string, unknown>).nested).toEqual({
+        value: 42,
+      });
+    });
+
+    it("is a no-op if from is missing", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "copy", path: "/b" });
+      expect(obj).toEqual({ a: 1 });
+    });
+  });
+
+  describe("test operation", () => {
+    it("succeeds when values match", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/name",
+          value: "Alice",
+        }),
+      ).not.toThrow();
+    });
+
+    it("succeeds for matching objects", () => {
+      const obj: Record<string, unknown> = { user: { name: "Alice", age: 30 } };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/user",
+          value: { name: "Alice", age: 30 },
+        }),
+      ).not.toThrow();
+    });
+
+    it("succeeds for matching arrays", () => {
+      const obj: Record<string, unknown> = { items: [1, 2, 3] };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/items",
+          value: [1, 2, 3],
+        }),
+      ).not.toThrow();
+    });
+
+    it("throws when values do not match", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/name",
+          value: "Bob",
+        }),
+      ).toThrow('Test operation failed: value at "/name" does not match');
+    });
+
+    it("throws when path does not exist", () => {
+      const obj: Record<string, unknown> = {};
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/missing",
+          value: "anything",
+        }),
+      ).toThrow();
+    });
+  });
+});
+
+// =============================================================================
+// compileSpecStream
+// =============================================================================
+
+describe("compileSpecStream", () => {
+  it("compiles a series of add patches", () => {
+    const stream = `{"op":"add","path":"/name","value":"Alice"}
+{"op":"add","path":"/age","value":30}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ name: "Alice", age: 30 });
+  });
+
+  it("handles remove operations", () => {
+    const stream = `{"op":"add","path":"/a","value":1}
+{"op":"add","path":"/b","value":2}
+{"op":"remove","path":"/a"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ b: 2 });
+    expect("a" in result).toBe(false);
+  });
+
+  it("handles replace operations", () => {
+    const stream = `{"op":"add","path":"/name","value":"Alice"}
+{"op":"replace","path":"/name","value":"Bob"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ name: "Bob" });
+  });
+
+  it("handles move operations", () => {
+    const stream = `{"op":"add","path":"/old","value":"data"}
+{"op":"move","path":"/new","from":"/old"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ new: "data" });
+  });
+
+  it("handles copy operations", () => {
+    const stream = `{"op":"add","path":"/original","value":"data"}
+{"op":"copy","path":"/duplicate","from":"/original"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ original: "data", duplicate: "data" });
+  });
+
+  it("skips empty lines", () => {
+    const stream = `{"op":"add","path":"/a","value":1}
+
+{"op":"add","path":"/b","value":2}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ a: 1, b: 2 });
+  });
+
+  it("uses initial value", () => {
+    const stream = `{"op":"add","path":"/b","value":2}`;
+    const result = compileSpecStream(stream, { a: 1 });
+    expect(result).toEqual({ a: 1, b: 2 });
+  });
+});
+
+// =============================================================================
+// createSpecStreamCompiler
+// =============================================================================
+
+describe("createSpecStreamCompiler", () => {
+  it("processes chunks incrementally", () => {
+    const compiler = createSpecStreamCompiler();
+    const { result, newPatches } = compiler.push(
+      '{"op":"add","path":"/name","value":"Alice"}\n',
+    );
+    expect(newPatches).toHaveLength(1);
+    expect(result).toEqual({ name: "Alice" });
+  });
+
+  it("handles partial lines across chunks", () => {
+    const compiler = createSpecStreamCompiler();
+
+    // First chunk is incomplete
+    const r1 = compiler.push('{"op":"add","path":"/na');
+    expect(r1.newPatches).toHaveLength(0);
+
+    // Complete the line
+    const r2 = compiler.push('me","value":"Alice"}\n');
+    expect(r2.newPatches).toHaveLength(1);
+    expect(r2.result).toEqual({ name: "Alice" });
+  });
+
+  it("processes remaining buffer on getResult", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/name","value":"Alice"}');
+    // No newline, so not processed yet
+    const result = compiler.getResult();
+    expect(result).toEqual({ name: "Alice" });
+  });
+
+  it("resets to clean state", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/name","value":"Alice"}\n');
+    compiler.reset();
+    const result = compiler.getResult();
+    expect(result).toEqual({});
+  });
+
+  it("tracks all applied patches", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/a","value":1}\n');
+    compiler.push('{"op":"add","path":"/b","value":2}\n');
+    const patches = compiler.getPatches();
+    expect(patches).toHaveLength(2);
+    expect(patches[0]!.op).toBe("add");
+    expect(patches[1]!.op).toBe("add");
+  });
+
+  it("handles all RFC 6902 operations", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/x","value":1}\n');
+    compiler.push('{"op":"add","path":"/y","value":2}\n');
+    compiler.push('{"op":"move","path":"/z","from":"/x"}\n');
+    compiler.push('{"op":"copy","path":"/w","from":"/y"}\n');
+    compiler.push('{"op":"remove","path":"/y"}\n');
+    const result = compiler.getResult();
+    expect(result).toEqual({ z: 1, w: 2 });
+  });
+});

+ 192 - 22
packages/core/src/types.ts

@@ -136,17 +136,20 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
 export type ValidationMode = "strict" | "warn" | "ignore";
 export type ValidationMode = "strict" | "warn" | "ignore";
 
 
 /**
 /**
- * JSON patch operation types
+ * JSON patch operation types (RFC 6902)
  */
  */
-export type PatchOp = "add" | "remove" | "replace" | "set";
+export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test";
 
 
 /**
 /**
- * JSON patch operation
+ * JSON patch operation (RFC 6902)
  */
  */
 export interface JsonPatch {
 export interface JsonPatch {
   op: PatchOp;
   op: PatchOp;
   path: string;
   path: string;
+  /** Required for add, replace, test */
   value?: unknown;
   value?: unknown;
+  /** Required for move, copy (source location) */
+  from?: string;
 }
 }
 
 
 /**
 /**
@@ -168,16 +171,30 @@ export function resolveDynamicValue<T>(
 }
 }
 
 
 /**
 /**
- * Get a value from an object by JSON Pointer path
+ * Unescape a JSON Pointer token per RFC 6901 Section 4.
+ * ~1 is decoded to / and ~0 is decoded to ~ (order matters).
+ */
+function unescapeJsonPointer(token: string): string {
+  return token.replace(/~1/g, "/").replace(/~0/g, "~");
+}
+
+/**
+ * Parse a JSON Pointer path into unescaped segments.
+ */
+function parseJsonPointer(path: string): string[] {
+  const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  return raw.map(unescapeJsonPointer);
+}
+
+/**
+ * Get a value from an object by JSON Pointer path (RFC 6901)
  */
  */
 export function getByPath(obj: unknown, path: string): unknown {
 export function getByPath(obj: unknown, path: string): unknown {
   if (!path || path === "/") {
   if (!path || path === "/") {
     return obj;
     return obj;
   }
   }
 
 
-  const segments = path.startsWith("/")
-    ? path.slice(1).split("/")
-    : path.split("/");
+  const segments = parseJsonPointer(path);
 
 
   let current: unknown = obj;
   let current: unknown = obj;
 
 
@@ -186,7 +203,10 @@ export function getByPath(obj: unknown, path: string): unknown {
       return undefined;
       return undefined;
     }
     }
 
 
-    if (typeof current === "object") {
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      current = current[index];
+    } else if (typeof current === "object") {
       current = (current as Record<string, unknown>)[segment];
       current = (current as Record<string, unknown>)[segment];
     } else {
     } else {
       return undefined;
       return undefined;
@@ -204,7 +224,7 @@ function isNumericIndex(str: string): boolean {
 }
 }
 
 
 /**
 /**
- * Set a value in an object by JSON Pointer path.
+ * Set a value in an object by JSON Pointer path (RFC 6901).
  * Automatically creates arrays when the path segment is a numeric index.
  * Automatically creates arrays when the path segment is a numeric index.
  */
  */
 export function setByPath(
 export function setByPath(
@@ -212,9 +232,7 @@ export function setByPath(
   path: string,
   path: string,
   value: unknown,
   value: unknown,
 ): void {
 ): void {
-  const segments = path.startsWith("/")
-    ? path.slice(1).split("/")
-    : path.split("/");
+  const segments = parseJsonPointer(path);
 
 
   if (segments.length === 0) return;
   if (segments.length === 0) return;
 
 
@@ -224,7 +242,8 @@ export function setByPath(
     const segment = segments[i]!;
     const segment = segments[i]!;
     const nextSegment = segments[i + 1];
     const nextSegment = segments[i + 1];
     const nextIsNumeric =
     const nextIsNumeric =
-      nextSegment !== undefined && isNumericIndex(nextSegment);
+      nextSegment !== undefined &&
+      (isNumericIndex(nextSegment) || nextSegment === "-");
 
 
     if (Array.isArray(current)) {
     if (Array.isArray(current)) {
       const index = parseInt(segment, 10);
       const index = parseInt(segment, 10);
@@ -242,13 +261,131 @@ export function setByPath(
 
 
   const lastSegment = segments[segments.length - 1]!;
   const lastSegment = segments[segments.length - 1]!;
   if (Array.isArray(current)) {
   if (Array.isArray(current)) {
-    const index = parseInt(lastSegment, 10);
-    current[index] = value;
+    if (lastSegment === "-") {
+      current.push(value);
+    } else {
+      const index = parseInt(lastSegment, 10);
+      current[index] = value;
+    }
   } else {
   } else {
     current[lastSegment] = value;
     current[lastSegment] = value;
   }
   }
 }
 }
 
 
+/**
+ * Add a value per RFC 6902 "add" semantics.
+ * For objects: create-or-replace the member.
+ * For arrays: insert before the given index, or append if "-".
+ */
+export function addByPath(
+  obj: Record<string, unknown>,
+  path: string,
+  value: unknown,
+): void {
+  const segments = parseJsonPointer(path);
+
+  if (segments.length === 0) return;
+
+  let current: Record<string, unknown> | unknown[] = obj;
+
+  for (let i = 0; i < segments.length - 1; i++) {
+    const segment = segments[i]!;
+    const nextSegment = segments[i + 1];
+    const nextIsNumeric =
+      nextSegment !== undefined &&
+      (isNumericIndex(nextSegment) || nextSegment === "-");
+
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      if (current[index] === undefined || typeof current[index] !== "object") {
+        current[index] = nextIsNumeric ? [] : {};
+      }
+      current = current[index] as Record<string, unknown> | unknown[];
+    } else {
+      if (!(segment in current) || typeof current[segment] !== "object") {
+        current[segment] = nextIsNumeric ? [] : {};
+      }
+      current = current[segment] as Record<string, unknown> | unknown[];
+    }
+  }
+
+  const lastSegment = segments[segments.length - 1]!;
+  if (Array.isArray(current)) {
+    if (lastSegment === "-") {
+      current.push(value);
+    } else {
+      const index = parseInt(lastSegment, 10);
+      current.splice(index, 0, value);
+    }
+  } else {
+    current[lastSegment] = value;
+  }
+}
+
+/**
+ * Remove a value per RFC 6902 "remove" semantics.
+ * For objects: delete the property.
+ * For arrays: splice out the element at the given index.
+ */
+export function removeByPath(obj: Record<string, unknown>, path: string): void {
+  const segments = parseJsonPointer(path);
+
+  if (segments.length === 0) return;
+
+  let current: Record<string, unknown> | unknown[] = obj;
+
+  for (let i = 0; i < segments.length - 1; i++) {
+    const segment = segments[i]!;
+
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      if (current[index] === undefined || typeof current[index] !== "object") {
+        return; // path does not exist
+      }
+      current = current[index] as Record<string, unknown> | unknown[];
+    } else {
+      if (!(segment in current) || typeof current[segment] !== "object") {
+        return; // path does not exist
+      }
+      current = current[segment] as Record<string, unknown> | unknown[];
+    }
+  }
+
+  const lastSegment = segments[segments.length - 1]!;
+  if (Array.isArray(current)) {
+    const index = parseInt(lastSegment, 10);
+    if (index >= 0 && index < current.length) {
+      current.splice(index, 1);
+    }
+  } else {
+    delete current[lastSegment];
+  }
+}
+
+/**
+ * Deep equality check for RFC 6902 "test" operation.
+ */
+function deepEqual(a: unknown, b: unknown): boolean {
+  if (a === b) return true;
+  if (a === null || b === null) return false;
+  if (typeof a !== typeof b) return false;
+  if (typeof a !== "object") return false;
+
+  if (Array.isArray(a)) {
+    if (!Array.isArray(b)) return false;
+    if (a.length !== b.length) return false;
+    return a.every((item, i) => deepEqual(item, b[i]));
+  }
+
+  const aObj = a as Record<string, unknown>;
+  const bObj = b as Record<string, unknown>;
+  const aKeys = Object.keys(aObj);
+  const bKeys = Object.keys(bObj);
+
+  if (aKeys.length !== bKeys.length) return false;
+  return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
+}
+
 /**
 /**
  * Find a form value from params and/or data.
  * Find a form value from params and/or data.
  * Useful in action handlers to locate form input values regardless of path format.
  * Useful in action handlers to locate form input values regardless of path format.
@@ -345,17 +482,50 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
 }
 }
 
 
 /**
 /**
- * Apply a single SpecStream patch to an object.
+ * Apply a single RFC 6902 JSON Patch operation to an object.
  * Mutates the object in place.
  * Mutates the object in place.
+ *
+ * Supports all six RFC 6902 operations: add, remove, replace, move, copy, test.
+ *
+ * @throws {Error} If a "test" operation fails (value mismatch).
  */
  */
 export function applySpecStreamPatch<T extends Record<string, unknown>>(
 export function applySpecStreamPatch<T extends Record<string, unknown>>(
   obj: T,
   obj: T,
   patch: SpecStreamLine,
   patch: SpecStreamLine,
 ): T {
 ): T {
-  if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
-    setByPath(obj, patch.path, patch.value);
-  } else if (patch.op === "remove") {
-    setByPath(obj, patch.path, undefined);
+  switch (patch.op) {
+    case "add":
+      addByPath(obj, patch.path, patch.value);
+      break;
+    case "replace":
+      // RFC 6902: target must exist. For streaming tolerance we set regardless.
+      setByPath(obj, patch.path, patch.value);
+      break;
+    case "remove":
+      removeByPath(obj, patch.path);
+      break;
+    case "move": {
+      if (!patch.from) break;
+      const moveValue = getByPath(obj, patch.from);
+      removeByPath(obj, patch.from);
+      addByPath(obj, patch.path, moveValue);
+      break;
+    }
+    case "copy": {
+      if (!patch.from) break;
+      const copyValue = getByPath(obj, patch.from);
+      addByPath(obj, patch.path, copyValue);
+      break;
+    }
+    case "test": {
+      const actual = getByPath(obj, patch.path);
+      if (!deepEqual(actual, patch.value)) {
+        throw new Error(
+          `Test operation failed: value at "${patch.path}" does not match`,
+        );
+      }
+      break;
+    }
   }
   }
   return obj;
   return obj;
 }
 }
@@ -365,8 +535,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
  * Each line should be a patch operation.
  * Each line should be a patch operation.
  *
  *
  * @example
  * @example
- * const stream = `{"op":"set","path":"/name","value":"Alice"}
- * {"op":"set","path":"/age","value":30}`;
+ * const stream = `{"op":"add","path":"/name","value":"Alice"}
+ * {"op":"add","path":"/age","value":30}`;
  * const result = compileSpecStream(stream);
  * const result = compileSpecStream(stream);
  * // { name: "Alice", age: 30 }
  * // { name: "Alice", age: 30 }
  */
  */

+ 94 - 41
packages/react/src/hooks.ts

@@ -7,7 +7,12 @@ import type {
   FlatElement,
   FlatElement,
   JsonPatch,
   JsonPatch,
 } from "@json-render/core";
 } from "@json-render/core";
-import { setByPath } from "@json-render/core";
+import {
+  setByPath,
+  getByPath,
+  addByPath,
+  removeByPath,
+} from "@json-render/core";
 
 
 /**
 /**
  * Parse a single JSON patch line
  * Parse a single JSON patch line
@@ -25,56 +30,104 @@ function parsePatchLine(line: string): JsonPatch | null {
 }
 }
 
 
 /**
 /**
- * Apply a JSON patch to the current spec
+ * Set a value at a spec path (for add/replace operations).
+ */
+function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
+  if (path === "/root") {
+    newSpec.root = value as string;
+    return;
+  }
+
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      newSpec.elements[elementKey] = value as UIElement;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        setByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+          value,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Remove a value at a spec path.
+ */
+function removeSpecValue(newSpec: Spec, path: string): void {
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      const { [elementKey]: _, ...rest } = newSpec.elements;
+      newSpec.elements = rest;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        removeByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Get a value at a spec path.
+ */
+function getSpecValue(spec: Spec, path: string): unknown {
+  if (path === "/root") return spec.root;
+  return getByPath(spec as unknown as Record<string, unknown>, path);
+}
+
+/**
+ * Apply an RFC 6902 JSON patch to the current spec.
+ * Supports add, remove, replace, move, copy, and test operations.
  */
  */
 function applyPatch(spec: Spec, patch: JsonPatch): Spec {
 function applyPatch(spec: Spec, patch: JsonPatch): Spec {
   const newSpec = { ...spec, elements: { ...spec.elements } };
   const newSpec = { ...spec, elements: { ...spec.elements } };
 
 
   switch (patch.op) {
   switch (patch.op) {
-    case "set":
     case "add":
     case "add":
     case "replace": {
     case "replace": {
-      // Handle root path
-      if (patch.path === "/root") {
-        newSpec.root = patch.value as string;
-        return newSpec;
-      }
-
-      // Handle elements paths
-      if (patch.path.startsWith("/elements/")) {
-        const pathParts = patch.path.slice("/elements/".length).split("/");
-        const elementKey = pathParts[0];
-
-        if (!elementKey) return newSpec;
-
-        if (pathParts.length === 1) {
-          // Setting entire element
-          newSpec.elements[elementKey] = patch.value as UIElement;
-        } else {
-          // Setting property of element
-          const element = newSpec.elements[elementKey];
-          if (element) {
-            const propPath = "/" + pathParts.slice(1).join("/");
-            const newElement = { ...element };
-            setByPath(
-              newElement as unknown as Record<string, unknown>,
-              propPath,
-              patch.value,
-            );
-            newSpec.elements[elementKey] = newElement;
-          }
-        }
-      }
+      setSpecValue(newSpec, patch.path, patch.value);
       break;
       break;
     }
     }
     case "remove": {
     case "remove": {
-      if (patch.path.startsWith("/elements/")) {
-        const elementKey = patch.path.slice("/elements/".length).split("/")[0];
-        if (elementKey) {
-          const { [elementKey]: _, ...rest } = newSpec.elements;
-          newSpec.elements = rest;
-        }
-      }
+      removeSpecValue(newSpec, patch.path);
+      break;
+    }
+    case "move": {
+      if (!patch.from) break;
+      const moveValue = getSpecValue(newSpec, patch.from);
+      removeSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, moveValue);
+      break;
+    }
+    case "copy": {
+      if (!patch.from) break;
+      const copyValue = getSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, copyValue);
+      break;
+    }
+    case "test": {
+      // test is a no-op for rendering purposes (validation only)
       break;
       break;
     }
     }
   }
   }

+ 10 - 9
packages/remotion/src/schema.ts

@@ -25,11 +25,12 @@ function remotionPromptTemplate(context: PromptContext): string {
   lines.push("");
   lines.push("");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("");
   lines.push("");
-  lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
-{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
-{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
-{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
-{"op":"set","path":"/audio","value":{"tracks":[]}}`);
+  lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
+{"op":"add","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
+{"op":"add","path":"/clips","value":[]}
+{"op":"add","path":"/clips/-","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
+{"op":"add","path":"/clips/-","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
+{"op":"add","path":"/audio","value":{"tracks":[]}}`);
   lines.push("");
   lines.push("");
 
 
   // Components
   // Components
@@ -107,10 +108,10 @@ function remotionPromptTemplate(context: PromptContext): string {
   lines.push("RULES:");
   lines.push("RULES:");
   const baseRules = [
   const baseRules = [
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
-    "First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
-    "Then set /tracks array with video/overlay tracks",
-    "Then set each clip: /clips/0, /clips/1, etc.",
-    "Finally set /audio with {tracks:[]}",
+    'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}',
+    'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}',
+    'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}',
+    'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}',
     "ONLY use components listed above",
     "ONLY use components listed above",
     "fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
     "fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
     'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
     'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác