Skip to content

Commit ba6df95

Browse files
k4ditanoclaude
andcommitted
Ken Burns: la foto quieta que respira
Una capa de imagen puede acercarse o alejarse por DENTRO de su huella a lo largo de su ventana: la capa no cambia de tamaño, respira su contenido con el recorte centrado. Es el plano de archivo de toda la vida, y el que pedía la tanda A de la nota de diseño. Va con zoompan y no con keyframes de escala, y no es capricho: las expresiones de `scale` están congeladas en el ffmpeg de hoy, y zoompan es lo único que anima un tamaño de verdad. Como `s=` solo acepta números, editar.py aprende a medir imágenes (`tamano_imagen`: PNG y JPEG por cabecera, ffprobe de respaldo); una imagen que no se puede medir se queda quieta en vez de tumbar el render. La imagen se escala de más antes de recortar, que recortar de una imagen justa es inventar píxeles, y el movimiento suave de la capa también suaviza el zoom. La previa hace el mismo zoom con su marco recortado, y la ficha lo ofrece en tres chips: Nada, Acercar, Alejar. Verificado con render real sobre un patrón de tablero —el contenido sale 1,6 veces más grande al final, huella intacta— y con la barra viva: los chips leen y escriben el plan. 98 pruebas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7fc3a75 commit ba6df95

4 files changed

Lines changed: 246 additions & 13 deletions

File tree

‎plugins/Captura/CapasLienzo.qml‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@ Item {
184184
+ (modelData.salida && modelData.salida.tipo === "deslizar"
185185
? 0.08 * (1 - rampaEfecto(modelData.salida, false)) : 0)
186186

187+
// El Ken Burns de la previa: el mismo zoom por dentro que hace
188+
// zoompan en el render, con su recorte centrado y su misma
189+
// rampa. La capa no cambia de tamaño; respira su contenido.
190+
readonly property real zoomKenburns: {
191+
const kb = modelData.kenburns
192+
if (!kb)
193+
return 1
194+
const z0 = Math.max(1, Math.min(3, Number(kb.desde) || 1))
195+
const z1 = Math.max(1, Math.min(3, Number(kb.hasta) || 1))
196+
let u = (lienzo.segundos - modelData.t0)
197+
/ Math.max(0.05, modelData.t1 - modelData.t0)
198+
u = Math.max(0, Math.min(1, u))
199+
if (modelData.suave)
200+
u = u * u * (3 - 2 * u)
201+
return z0 + (z1 - z0) * u
202+
}
203+
187204
readonly property bool esTexto: modelData.tipo === "texto"
188205
readonly property bool esPip: modelData.tipo === "video"
189206
readonly property bool esZona: modelData.tipo === "zona"
@@ -419,18 +436,27 @@ Item {
419436
}
420437
}
421438

422-
Image {
423-
id: imagen
439+
// Con marco recortado: el zoom del Ken Burns agranda la imagen
440+
// por dentro y lo que se sale por los bordes no se ve, igual
441+
// que hace zoompan con su ventana.
442+
Item {
424443
anchors.fill: parent
444+
clip: true
425445
visible: !capa.esTexto && !capa.esPip && !capa.esZona
426-
source: !capa.esTexto && !capa.esPip && capa.modelData.ruta
427-
? "file://" + capa.modelData.ruta : ""
428-
fillMode: Image.Stretch
429-
// El PNG llega a menudo mucho más grande que el hueco; sin esto
430-
// se guarda en memoria a tamaño completo por cada capa.
431-
sourceSize.width: Math.max(64, width)
432-
smooth: true
433-
asynchronous: true
446+
447+
Image {
448+
id: imagen
449+
anchors.fill: parent
450+
scale: capa.zoomKenburns
451+
source: !capa.esTexto && !capa.esPip && capa.modelData.ruta
452+
? "file://" + capa.modelData.ruta : ""
453+
fillMode: Image.Stretch
454+
// El PNG llega a menudo mucho más grande que el hueco; sin esto
455+
// se guarda en memoria a tamaño completo por cada capa.
456+
sourceSize.width: Math.max(64, width)
457+
smooth: true
458+
asynchronous: true
459+
}
434460
}
435461

436462
// ── el rótulo ─────────────────────────────────────────

‎plugins/Captura/FichaCapa.qml‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,70 @@ ColumnLayout {
346346
}
347347
}
348348

349+
// El Ken Burns: la foto quieta que respira. Zoom por dentro de la
350+
// huella —la capa no cambia de tamaño— a lo largo de su ventana.
351+
IslandLabel {
352+
visible: Editor.capaSel && Editor.capaSel.tipo === "imagen"
353+
text: Idioma.t("Ken Burns")
354+
color: Theme.dim
355+
font.pixelSize: 9
356+
font.capitalization: Font.AllUppercase
357+
font.weight: Font.DemiBold
358+
}
359+
360+
RowLayout {
361+
visible: Editor.capaSel && Editor.capaSel.tipo === "imagen"
362+
Layout.fillWidth: true
363+
spacing: 3
364+
365+
Repeater {
366+
model: [
367+
{ id: "", nombre: Idioma.t("Nada") },
368+
{ id: "acercar", nombre: Idioma.t("Acercar") },
369+
{ id: "alejar", nombre: Idioma.t("Alejar") }
370+
]
371+
372+
delegate: Rectangle {
373+
id: chipKb
374+
required property var modelData
375+
376+
readonly property var kb: Editor.capaSel
377+
? Editor.capaSel.kenburns : null
378+
readonly property string puestoId: !kb ? ""
379+
: (Number(kb.hasta) > Number(kb.desde) ? "acercar"
380+
: "alejar")
381+
readonly property bool puesta: puestoId === chipKb.modelData.id
382+
383+
Layout.fillWidth: true
384+
Layout.preferredHeight: 24
385+
radius: 12
386+
color: chipKb.puesta ? Theme.blue
387+
: kbRaton.containsMouse ? Theme.surfaceHi : Theme.surface
388+
389+
IslandLabel {
390+
anchors.centerIn: parent
391+
text: chipKb.modelData.nombre
392+
color: chipKb.puesta ? "#ffffff" : Theme.muted
393+
font.pixelSize: 10
394+
font.weight: chipKb.puesta ? Font.DemiBold : Font.Normal
395+
}
396+
397+
MouseArea {
398+
id: kbRaton
399+
anchors.fill: parent
400+
hoverEnabled: true
401+
cursorShape: Qt.PointingHandCursor
402+
onClicked: Editor.fijarCapa(Editor.idSel, {
403+
kenburns: chipKb.modelData.id === "" ? null
404+
: chipKb.modelData.id === "acercar"
405+
? { desde: 1.0, hasta: 1.25 }
406+
: { desde: 1.25, hasta: 1.0 }
407+
})
408+
}
409+
}
410+
}
411+
}
412+
349413
// Inspector numérico: permite repetir posiciones y
350414
// tamaños con precisión, sin depender del ratón.
351415
GridLayout {

‎tools/editar.py‎

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -736,9 +736,10 @@ def efecto_de(capa, cual):
736736

737737

738738
def capa_animada(capa):
739-
"""Si los filtros de la capa dependen del tiempo: claves o efectos."""
739+
"""Si los filtros de la capa dependen del tiempo: claves, efectos o
740+
Ken Burns."""
740741
return bool(capa.get("keyframes")) or bool(efecto_de(capa, "entrada")) \
741-
or bool(efecto_de(capa, "salida"))
742+
or bool(efecto_de(capa, "salida")) or bool(kenburns_de(capa))
742743

743744

744745
# Cuánto empuja «deslizar», en fracción del alto del fotograma.
@@ -773,6 +774,66 @@ def efectos_video(capa):
773774
return fades, dys
774775

775776

777+
def tamano_imagen(ruta):
778+
"""(ancho, alto) leídos de la cabecera del fichero, o None.
779+
780+
PNG y JPEG a mano —es leer unos bytes— y ffprobe de respaldo para lo
781+
demás. Hace falta para el Ken Burns: `zoompan` exige el tamaño de salida
782+
en números de verdad, no en expresiones.
783+
"""
784+
try:
785+
with open(ruta, "rb") as f:
786+
cab = f.read(24)
787+
if cab[:8] == b"\x89PNG\r\n\x1a\n" and len(cab) >= 24:
788+
return (int.from_bytes(cab[16:20], "big"),
789+
int.from_bytes(cab[20:24], "big"))
790+
if cab[:2] == b"\xff\xd8":
791+
# JPEG: saltar de marca en marca hasta el SOF, que es quien
792+
# lleva el tamaño. C4, C8 y CC parecen SOF y no lo son.
793+
f.seek(2)
794+
while True:
795+
marca = f.read(4)
796+
if len(marca) < 4 or marca[0] != 0xFF:
797+
break
798+
tipo = marca[1]
799+
largo = int.from_bytes(marca[2:4], "big")
800+
if 0xC0 <= tipo <= 0xCF and tipo not in (0xC4, 0xC8, 0xCC):
801+
datos = f.read(5)
802+
if len(datos) < 5:
803+
break
804+
return (int.from_bytes(datos[3:5], "big"),
805+
int.from_bytes(datos[1:3], "big"))
806+
f.seek(largo - 2, 1)
807+
except OSError:
808+
return None
809+
p = subprocess.run(
810+
["ffprobe", "-v", "error", "-select_streams", "v:0",
811+
"-show_entries", "stream=width,height", "-of", "csv=p=0", ruta],
812+
capture_output=True, text=True)
813+
partes = p.stdout.strip().split(",")
814+
if len(partes) == 2 and partes[0].isdigit() and partes[1].isdigit():
815+
return (int(partes[0]), int(partes[1]))
816+
return None
817+
818+
819+
def kenburns_de(capa):
820+
"""El Ken Burns de una capa de imagen, normalizado, o None.
821+
822+
{desde, hasta}: cuánto zoom al empezar y al acabar su ventana, por DENTRO
823+
de la huella —la capa no cambia de tamaño, respira el contenido—. Acotado
824+
a 1..3: por debajo de 1 zoompan no sabe alejarse y por encima de 3 una
825+
foto normal ya es papilla.
826+
"""
827+
kb = capa.get("kenburns") or {}
828+
if capa.get("tipo") != "imagen":
829+
return None
830+
z0 = encaja(float(kb.get("desde", 1.0) or 1.0), 1.0, 3.0)
831+
z1 = encaja(float(kb.get("hasta", 1.0) or 1.0), 1.0, 3.0)
832+
if abs(z1 - z0) < 0.01:
833+
return None
834+
return {"desde": z0, "hasta": z1}
835+
836+
776837
def alfa_texto(capa):
777838
"""La expresión de alfa de un rótulo con efectos, o None.
778839
@@ -978,7 +1039,31 @@ def rama_capa(n, idx, capa, ancho, alto, entra):
9781039
expresion_animada(capa, "rotacion", rotacion))
9791040
else:
9801041
filtros.append("rotate=%.6f*PI/180:fillcolor=none" % rotacion)
981-
if capa.get("keyframes"):
1042+
# El Ken Burns: zoom por DENTRO de la huella, con zoompan, que es el
1043+
# único camino que de verdad anima un tamaño —las expresiones de `scale`
1044+
# están congeladas, ver arriba—. La capa no cambia de medida: se acerca
1045+
# su contenido, con el recorte centrado. Exige saber cuánto mide la
1046+
# imagen, porque `s=` solo acepta números; si no se puede medir, la capa
1047+
# se queda quieta y ya.
1048+
kb = kenburns_de(capa)
1049+
dim = tamano_imagen(capa.get("ruta", "")) if kb else None
1050+
if kb and dim:
1051+
iw, ih = dim
1052+
hf = max(2, int(round(ancho_capa * ih / max(1, iw))))
1053+
zmax = max(kb["desde"], kb["hasta"])
1054+
t0, t1 = float(capa.get("t0", 0.0)), float(capa.get("t1", 0.0))
1055+
progreso = "clip((it-%.4f)/%.4f,0,1)" % (t0, max(0.05, t1 - t0))
1056+
if capa.get("suave"):
1057+
progreso = "%s*%s*(3-2*%s)" % (progreso, progreso, progreso)
1058+
# Se escala ANTES a lo que pide el zoom máximo: zoompan recorta de su
1059+
# entrada, y recortar de una imagen justa es inventar píxeles.
1060+
filtros.append("scale=%d:-1" % int(round(ancho_capa * zmax + 2)))
1061+
filtros.append(
1062+
"zoompan=z='%.4f+%.4f*%s':x='iw/2-iw/zoom/2':"
1063+
"y='ih/2-ih/zoom/2':d=1:s=%dx%d:fps=25"
1064+
% (kb["desde"], kb["hasta"] - kb["desde"], progreso,
1065+
ancho_capa, hf))
1066+
elif capa.get("keyframes"):
9821067
filtros.append("scale=w='round(%d*%s)':h=-1:eval=frame" % (ancho, escala_expr))
9831068
else:
9841069
filtros.append("scale=%d:-1" % ancho_capa)

‎tools/prueba_editar.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,64 @@ def prueba_efecto_no_toca_una_capa_quieta():
11691169
igual("la escala sigue siendo un número", "scale=480:-1" in texto, True)
11701170

11711171

1172+
def png_de_prueba(nombre, ancho, alto):
1173+
"""Un PNG mínimo de verdad, para lo que necesita leer cabeceras."""
1174+
import struct, zlib
1175+
def bloque(tipo, datos):
1176+
return (struct.pack(">I", len(datos)) + tipo + datos
1177+
+ struct.pack(">I", zlib.crc32(tipo + datos)))
1178+
ihdr = struct.pack(">IIBBBBB", ancho, alto, 8, 2, 0, 0, 0)
1179+
idat = zlib.compress((b"\x00" + b"\x7f\x7f\x7f" * ancho) * alto)
1180+
ruta = os.path.join(BORRADOR, nombre)
1181+
with open(ruta, "wb") as f:
1182+
f.write(b"\x89PNG\r\n\x1a\n" + bloque(b"IHDR", ihdr)
1183+
+ bloque(b"IDAT", idat) + bloque(b"IEND", b""))
1184+
return ruta
1185+
1186+
1187+
def prueba_tamano_imagen():
1188+
ruta = png_de_prueba("medible.png", 200, 100)
1189+
igual("el PNG se mide por su cabecera",
1190+
editar.tamano_imagen(ruta), (200, 100))
1191+
igual("un fichero vacío no es una imagen",
1192+
editar.tamano_imagen(fichero("vacio.bin")), None)
1193+
1194+
1195+
def prueba_kenburns_mete_zoompan():
1196+
"""El zoom por dentro va con zoompan, que es lo único que anima un tamaño
1197+
de verdad; conserva la huella (s=an×al de la capa) y su rampa recorre la
1198+
ventana."""
1199+
ruta = png_de_prueba("kb.png", 200, 100)
1200+
texto, _ = editar.grafo(con_capas(
1201+
[capa(ruta=ruta, kenburns={"desde": 1.0, "hasta": 1.25})]))
1202+
igual("zoompan con la rampa de la ventana",
1203+
"zoompan=z='1.0000+0.2500*clip((it-2.0000)/2.0000,0,1)'" in texto,
1204+
True)
1205+
igual("la huella no cambia: 480×240 de un PNG 2:1",
1206+
"s=480x240" in texto, True)
1207+
igual("y se escala de más antes de recortar", "scale=602:-1" in texto, True)
1208+
1209+
1210+
def prueba_kenburns_sin_tamano_se_queda_quieto():
1211+
"""Una imagen que no se puede medir no puede respirar: capa quieta, y el
1212+
render sale igual que siempre en vez de caerse."""
1213+
texto, _ = editar.grafo(con_capas(
1214+
[capa(kenburns={"desde": 1.0, "hasta": 1.25})]))
1215+
# Ojo: el zoompan de la CÁMARA siempre está; lo que no puede aparecer es
1216+
# el del Ken Burns, que se reconoce por su rampa con `it`.
1217+
igual("sin tamaño no hay zoompan de capa", "*clip((it-" in texto, False)
1218+
igual("la escala de siempre", "scale=480:-1" in texto, True)
1219+
1220+
1221+
def prueba_kenburns_suave_lleva_smoothstep():
1222+
ruta = png_de_prueba("kb2.png", 100, 100)
1223+
texto, _ = editar.grafo(con_capas(
1224+
[capa(ruta=ruta, suave=True,
1225+
kenburns={"desde": 1.25, "hasta": 1.0})]))
1226+
igual("la rampa del zoom también se suaviza",
1227+
"*(3-2*clip((it-2.0000)/2.0000,0,1))'" in texto, True)
1228+
1229+
11721230
def prueba_keyframes_suaves():
11731231
"""`suave: true` mete la smoothstep u²(3−2u) en la expresión; sin él la
11741232
recta de siempre, intacta."""

0 commit comments

Comments
 (0)