Skip to content

Commit 3c585cf

Browse files
anpawoclaude
andcommitted
feat: an effect knows the call that wrote it
The timeline could say a shader ran between two frames. It could not say what in the scene asked for it, so a row named `Scale` was talking about the renderer while the person was looking at `scaleTo(0.5, duration=1.2)`. Every `apply()` now leaves a statement behind — file, line, the library function the person actually called, and the frames each shader key was written over. The serialiser hands each run to the statement that covers most of it, and between statements that cover it equally, to the one that touched the fewest kinds: a group re-emits position, rotation and scale on every pass, so `scaleTo` is a better answer for a Scale run than `rotateBy`. Runs from one statement become one row, and that row is the SHORTEST of them rather than their union. A group's `scaleTo` writes scale for its own second and a half and position for as long as anything in the group moves; the union said the call lasted as long as the group did, so shortening it left the bar exactly where it was. `removeCallSpan` is the other half of writing: the span that takes a call OUT. A link in a chain loses its link, a statement of its own takes its line with it, and a call nested in something else — the `Square(...)` that made the element — is refused rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0e8b63d commit 3c585cf

5 files changed

Lines changed: 308 additions & 7 deletions

File tree

test/edit_test.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
sys.path.insert(0, "test")
1414
from helpers import check, section, summary
1515

16-
from videocode.edit import argumentSpan, callLine, findCalls, readArgument, removeArgument, setArgument
16+
from videocode.edit import (
17+
argumentSpan,
18+
callLine,
19+
findCalls,
20+
positionalSpan,
21+
readArgument,
22+
removeArgument,
23+
removeCallSpan,
24+
setArgument,
25+
)
1726

1827
SOURCE = '''#!/usr/bin/env python3
1928
@@ -130,5 +139,62 @@ def untouched(before: str, after: str, except_: int) -> bool:
130139
edit = setArgument(broken, 1, "Square", "side", "2")
131140
check("returned unchanged", edit.source == broken and not edit.changed)
132141

142+
# ── An argument with no name ───────────────────────────────────────────────
143+
section("positionalSpan — `wait(0.3)` writes its seconds without a name")
144+
span = positionalSpan(SOURCE, 8, "wait", 0, "0.6")
145+
check("a span was given", span is not None)
146+
if span is not None:
147+
start, end, text = span
148+
check("it replaces the number, nothing more", SOURCE[start:end] == "0.3" and text == "0.6")
149+
edited = SOURCE[:start] + text + SOURCE[end:]
150+
check("the line reads as written", line(edited, 8) == "wait(0.6)")
151+
check("every other line is untouched", untouched(SOURCE, edited, 8))
152+
153+
check("the same value is no span at all", positionalSpan(SOURCE, 8, "wait", 0, "0.3") is None)
154+
155+
empty = "wait()\n"
156+
span = positionalSpan(empty, 1, "wait", 0, "0.5")
157+
check("an argument that is not there yet is appended", span is not None)
158+
if span is not None:
159+
start, end, text = span
160+
check("with no stray separator", empty[:start] + text + empty[end:] == "wait(0.5)\n")
161+
162+
check("a slot cannot be skipped", positionalSpan(empty, 1, "wait", 2, "0.5") is None)
163+
164+
# ── Taking a call away ─────────────────────────────────────────────────────
165+
section("removeCallSpan — a link in a chain loses only its link")
166+
span = removeCallSpan(SOURCE, 10, "scaleTo")
167+
check("a span was given", span is not None)
168+
if span is not None:
169+
start, end, text = span
170+
edited = SOURCE[:start] + text + SOURCE[end:]
171+
check("the scale is gone", line(edited, 10) == "Group(square, circle).rotateBy(180, duration=1.5)")
172+
check("the file still parses", isinstance(compile(edited, "scene.py", "exec"), object))
173+
check("every other line is untouched", untouched(SOURCE, edited, 10))
174+
175+
span = removeCallSpan(SOURCE, 10, "rotateBy")
176+
check("the middle link goes too, and only it", span is not None)
177+
if span is not None:
178+
start, end, text = span
179+
edited = SOURCE[:start] + text + SOURCE[end:]
180+
check("what is left is the rest of the chain",
181+
line(edited, 10) == "Group(square, circle).scaleTo(0.5, duration=0.5)")
182+
183+
section("removeCallSpan — a statement of its own takes its line")
184+
alone = "square = Square(side=1)\nsquare.fadeIn()\nsquare.moveBy(x=1)\n"
185+
span = removeCallSpan(alone, 2, "fadeIn")
186+
check("a span was given", span is not None)
187+
if span is not None:
188+
start, end, text = span
189+
edited = alone[:start] + text + alone[end:]
190+
check("the line went with it, newline included",
191+
edited == "square = Square(side=1)\nsquare.moveBy(x=1)\n")
192+
check("no bare name left behind", "square\n" not in edited)
193+
194+
section("removeCallSpan — what it refuses")
195+
check("a call that is not there", removeCallSpan(SOURCE, 10, "moveBy") is None)
196+
check("the call that MADE the element", removeCallSpan(SOURCE, 5, "Square") is None)
197+
check("broken source", removeCallSpan("square.fadeIn(\n", 1, "fadeIn") is None)
198+
133199
# ── summary ────────────────────────────────────────────────────────────────
134200
summary()

videocode/context.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,18 @@ def _callSite() -> tuple[str, int, str]:
275275

276276
frame = sys._getframe(2)
277277
cls = ""
278+
# The last library function crossed on the way out — `moveBy`,
279+
# `scaleTo`, `fadeIn`. The stack knows which method a person called; the
280+
# shaders it produced do not, and the editor needs it to offer "remove
281+
# this" on the right call of a chain rather than on the whole line.
282+
func = ""
278283
while frame is not None:
279284
name = frame.f_code.co_filename
280285
if not name.startswith(library):
286+
Context.lastCallFunction = func
281287
return (name, frame.f_lineno, cls)
288+
if not frame.f_code.co_name.startswith("_") and frame.f_code.co_name not in ("apply", "broadcast", "noteStatement"):
289+
func = frame.f_code.co_name
282290
# The OUTERMOST library frame's `self`, not the innermost: a
283291
# `Text` builds `Letter`s, and the letter is an implementation
284292
# detail of the word. Overwriting as the walk goes outward leaves
@@ -287,8 +295,36 @@ def _callSite() -> tuple[str, int, str]:
287295
if owner is not None:
288296
cls = type(owner).__name__
289297
frame = frame.f_back
298+
Context.lastCallFunction = func
290299
return ("", 0, cls)
291300

301+
# One entry per apply() that reached the stack: which input, which line of
302+
# the person's file, which shader keys, and the frames it covers. A SIDE
303+
# TABLE like `origin` — the stack itself is handed to C++ and diffed, so
304+
# nothing that is only for the editor may live in it.
305+
statements: list[dict[str, Any]] = []
306+
307+
# Filled by the last `_callSite()`: the library function the person called.
308+
lastCallFunction: str = ""
309+
310+
@staticmethod
311+
def noteStatement(inputIndex: int, touched: dict[str, list[int]]) -> None:
312+
"""
313+
Record where a statement was written, and what it covers.
314+
315+
The line is read here rather than per shader: the walk out of the library
316+
costs about 2 µs, an animation is hundreds of shaders, and every one of
317+
them came from the same line anyway.
318+
"""
319+
file, line, _ = Context._callSite()
320+
Context.statements.append({
321+
"file": file,
322+
"line": line,
323+
"call": Context.lastCallFunction,
324+
"input": inputIndex,
325+
"keys": {name: (span[0], span[1]) for name, span in touched.items()},
326+
})
327+
292328
@staticmethod
293329
def apply(inputIndex: int, shaderName: str, shaderType: str, shaderArgs: dict[str, Any]):
294330
frameIdx = shaderArgs["start"]

videocode/edit.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,47 @@ def argumentSpan(
214214
return closing, closing, f"{separator}{name}={value}"
215215

216216

217+
def positionalSpan(
218+
source: str,
219+
line: int,
220+
call: str,
221+
index: int,
222+
value: str,
223+
occurrence: int = 0,
224+
) -> tuple[int, int, str] | None:
225+
"""
226+
The same as `argumentSpan`, for an argument that has no name.
227+
228+
`wait(0.3)` is the case that asked for it: the number is written in the
229+
brackets, not after an `=`, and a timeline that lets you drag a gap has to
230+
rewrite exactly that. Missing arguments are appended in order — `wait()`
231+
takes a first positional the way `wait(0.3)` already has one.
232+
"""
233+
node = _pick(source, line, call, occurrence)
234+
if node is None:
235+
return None
236+
237+
if index < len(node.args):
238+
start, end = _span(source, node.args[index])
239+
if source[start:end] == value:
240+
return None
241+
return start, end, value
242+
243+
# Only the argument straight after the last one written can be added: there
244+
# is no way to skip a slot without naming it, and guessing a name here would
245+
# be inventing part of a signature.
246+
if index != len(node.args) or node.keywords:
247+
return None
248+
249+
start, end = _span(source, node)
250+
closing = source.rfind(")", start, end)
251+
if closing < 0:
252+
return None
253+
254+
separator = ", " if node.args else ""
255+
return closing, closing, f"{separator}{value}"
256+
257+
217258
def removeArgument(source: str, line: int, call: str, name: str, occurrence: int = 0) -> Edit:
218259
"""
219260
Take a keyword argument out, and the separator that came with it.
@@ -249,6 +290,70 @@ def removeArgument(source: str, line: int, call: str, name: str, occurrence: int
249290
return Edit(source, False, f"no argument named {name!r}")
250291

251292

293+
def removeCallSpan(
294+
source: str,
295+
line: int,
296+
call: str,
297+
occurrence: int = 0,
298+
) -> tuple[int, int, str] | None:
299+
"""
300+
The span that takes a call away, as a range to replace with nothing.
301+
302+
Two shapes, told apart by what is left behind:
303+
304+
- a link in a chain — `Group(a, b).scaleTo(0.5).rotateBy(180)` — where only
305+
`.scaleTo(0.5)` goes, from the end of what it was called on to its own end;
306+
- a statement of its own — `square.fadeIn()` — where removing the link would
307+
leave the bare name `square` sitting on a line, so the line goes with it,
308+
newline included.
309+
310+
`None` when the call is not there, or when it is nested inside something
311+
else (an argument, an assignment's value): taking it out then changes what
312+
the surrounding expression means, and a gesture may not do that quietly.
313+
"""
314+
node = _pick(source, line, call, occurrence)
315+
if node is None:
316+
return None
317+
318+
start, end = _span(source, node)
319+
320+
# Where the link begins: just after whatever it was called on.
321+
if not isinstance(node.func, ast.Attribute):
322+
receiverEnd = start
323+
else:
324+
receiverEnd = _span(source, node.func.value)[1]
325+
326+
try:
327+
tree = ast.parse(source)
328+
except SyntaxError:
329+
return None
330+
331+
# The statement this call sits in, and whether the call IS all of it.
332+
for statement in ast.walk(tree):
333+
if not isinstance(statement, ast.Expr):
334+
continue
335+
if _span(source, statement) != (start, end) and _span(source, statement.value) != (start, end):
336+
continue
337+
338+
# Only when nothing else on the line does anything. `square.fadeIn()`
339+
# leaves a bare name behind, so the line goes; but the last link of
340+
# `Group(a, b).scaleTo(0.5).rotateBy(180)` is also the outermost call,
341+
# and taking its line would take the scale with it.
342+
if any(isinstance(inner, ast.Call) for inner in ast.walk(node.func)):
343+
break
344+
345+
offsets = _offsets(source)
346+
lineStart = offsets[statement.lineno]
347+
lineEnd = offsets[(statement.end_lineno or statement.lineno) + 1] if (statement.end_lineno or statement.lineno) + 1 < len(offsets) else len(source)
348+
if source[lineStart:_span(source, statement)[0]].strip() == "":
349+
return lineStart, lineEnd, ""
350+
351+
# A link in a chain, and only a link.
352+
if receiverEnd == start:
353+
return None
354+
return receiverEnd, end, ""
355+
356+
252357
def callLine(source: str, name: str, occurrence: int = 0) -> int:
253358
"""
254359
Where a call is, for a caller that knows the name but not the line.

videocode/input/input.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def apply(self, *shaders: IShader | Effect | GroupEffect, start: sec = 0, durati
9494
if Context.waitOffset >= self.meta.transformationOffset:
9595
self.waitTo(Context.waitOffset)
9696

97+
touched: dict[str, list[int]] = {}
98+
9799
flatten: list[IShader] = []
98100
for s in shaders:
99101
if not isinstance(s, IShader):
@@ -156,12 +158,31 @@ def apply(self, *shaders: IShader | Effect | GroupEffect, start: sec = 0, durati
156158
args = {k: v for k, v in vars(s).items() if k not in ("start", "duration", "offset")} | {"start": __start, "duration": __duration}
157159

158160
# Add step to the stack
159-
Context.apply(self.meta.index, upperFirst(s.__class__.__name__), s._type, args)
161+
key = upperFirst(s.__class__.__name__)
162+
Context.apply(self.meta.index, key, s._type, args)
163+
164+
# What this STATEMENT touched, for the editor.
165+
#
166+
# A shader on the stack says what happens on a frame; it does not say
167+
# which line asked for it, and the editor needs that to let you move,
168+
# shorten or delete an effect by its bar rather than by finding the
169+
# call yourself. Gathered per apply() — one call is one statement —
170+
# and the source line is read once at the end, because the walk out
171+
# of the library costs 2 µs and an animation is hundreds of shaders.
172+
span = touched.get(key)
173+
if span is None:
174+
touched[key] = [__start, __end]
175+
else:
176+
span[0] = min(span[0], __start)
177+
span[1] = max(span[1], __end)
160178

161179
# Post-callbacks
162180
for callback in self.meta.postCallbacks.get(type(s), []):
163181
callback(s, start, duration, offset if offset is not None else self.meta.transformationOffset)
164182

183+
if touched:
184+
Context.noteStatement(self.meta.index, touched)
185+
165186
return self
166187

167188
def __setattr__(self, name: str, value: Any) -> None:

videocode/serialize.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,84 @@ def sceneModel() -> dict:
148148
else:
149149
spans.append([frame, frame])
150150

151-
effects = [
152-
{"name": key, "start": span[0], "end": span[1]}
153-
for key, spans in runs.items()
154-
for span in spans
155-
]
151+
# Where each run was WRITTEN.
152+
#
153+
# A run is frames on the stack; the editor needs the line that asked for
154+
# them, or an effect can only be read, never moved, shortened or deleted.
155+
# `Context.statements` holds one entry per apply() — the line and the
156+
# frames it covered — so a run is attributed to the statement of the same
157+
# input whose span for that shader key overlaps it. A group emits per
158+
# frame, so several statements carry the same line; the earliest wins,
159+
# which is the one a person would point at.
160+
def wroteIt(key: str, first: int, last: int) -> tuple[int, str]:
161+
# Whoever covers the most of it. Consecutive frames are merged into
162+
# one run, so a run can span two statements — `Circle().opacity(0)`
163+
# writing frame 0 and `fadeIn()` writing the ten after it — and the
164+
# one that owns two frames of twelve is not the one a person means.
165+
best, call, widest, kinds = 0, "", 0, 99
166+
for statement in Context.statements:
167+
if statement["input"] != index:
168+
continue
169+
span = statement["keys"].get(key)
170+
if span is None:
171+
continue
172+
overlap = min(span[1], last) - max(span[0], first) + 1
173+
if overlap <= 0:
174+
continue
175+
# Most of the run wins; between statements that cover it
176+
# equally, the one that touched the FEWEST kinds of shader — a
177+
# group re-emits position, rotation and scale on every pass, so
178+
# `scaleTo` (position and scale) is a better answer for a Scale
179+
# run than `rotateBy` (all three).
180+
spread = len(statement["keys"])
181+
if overlap > widest or (overlap == widest and spread < kinds):
182+
best, call, widest, kinds = statement["line"], statement["call"], overlap, spread
183+
return best, call
184+
185+
effects = []
186+
for key, spans in runs.items():
187+
for span in spans:
188+
line, call = wroteIt(key, span[0], span[1])
189+
effects.append({
190+
"name": key,
191+
"start": span[0],
192+
"end": span[1],
193+
"line": line,
194+
# What the person wrote, when it is known: an editor that
195+
# says `scaleTo` is talking about their scene, one that says
196+
# `Scale` is talking about ours.
197+
"call": call,
198+
})
199+
# One row per STATEMENT, not per kind of shader.
200+
#
201+
# A single `scaleTo` on a group writes both position and scale, and two
202+
# bars called `scaleTo` sitting on the same frames describe one thing
203+
# twice. Runs from the same call on the same line become one row, and
204+
# what they wrote is kept in `kinds`.
205+
#
206+
# The row is the SHORTEST of them, not their union. A group's `scaleTo`
207+
# writes scale for its own second and a half, and position for as long
208+
# as ANYTHING in the group is moving — the rigid recomputation that
209+
# keeps the members' places consistent. The union says the call lasts as
210+
# long as the group does, which is why shortening it left the bar where
211+
# it was; the shortest run is the animation the call actually asked for.
212+
folded: dict[tuple[int, str], dict] = {}
213+
loose: list[dict] = []
214+
for effect in effects:
215+
key = (effect["line"], effect["call"])
216+
if effect["line"] == 0 or not effect["call"]:
217+
loose.append(effect)
218+
continue
219+
held = folded.get(key)
220+
if held is None:
221+
folded[key] = effect | {"kinds": [effect["name"]]}
222+
continue
223+
held["kinds"].append(effect["name"])
224+
if effect["end"] - effect["start"] < held["end"] - held["start"]:
225+
held["start"] = effect["start"]
226+
held["end"] = effect["end"]
227+
228+
effects = loose + list(folded.values())
156229
effects.sort(key=lambda e: (e["start"], e["name"]))
157230

158231
# ── When it is actually on screen ─────────────────────────────────

0 commit comments

Comments
 (0)