8 nov. 2012

Scriptting para UI Facial

| No comment


Autor: Iker de los Mozos

'Character TD en Walt Disney Animation Studios, The Mill y Supervisor de Rigging en Kandor Graphics


En este artículo vamos a comentar un script para conectar los morphs o shapes de un personaje a una interfaz (compuesta por curvas en el entorno 3D) que previamente he creado. En lugar de tener un botón que conecte y desconecte todo el sistema, vamos a permitir al usuario cierta interacción a la hora de configurarlo, de manera que la herramienta gana en versatilidad y otorga más posibilidades.




En primer lugar, elegiremos la cabeza que almacena los morphs. Luego, el joystick o control que disparará una pista. Cada control tiene varias posiciones posibles para lanzar esa expresión facial. Seleccionando una de las pistas y pulsando luego el botón de la posición elegida, sólo restará pulsar el botón ‘conectar’ para que se ate ese control a los morphs elegidos.

Antes de empezar con la construcción del código, quisiera hacer unas pequeñas puntualizaciones.

Ésta es sólo una de las múltiples maneras de hacerlo. Tengo entendido que podemos distinguir dos fases cuando programamos:

La primera, en la que se busca que el código funcione.
En la segunda, una vez que ya funciona, se trata de hacer el script más 'elegante'. No tenemos por qué terminar la primera necesariamente antes de pasar a la segunda, ya que cuando uno programa va puliendo y optimizando el código a medida que lo escribe.
Del mismo modo, un script no se escribe 'de arriba a abajo', sino que va creciendo por muchas partes, yendo siempre de lo más general a lo más particular. A medida que vayamos necesitando funcionalidad iremos engrosando la cantidad de líneas.

Sí que conviene, sin embargo, planificar un 'armazón' o guía antes de empezar a 'picar codigo'.

En muchos casos, un script nos sirve para automatizar una serie de comandos u órdenes, y éstos tienen una correspondencia directa con el comando que se ejecuta al pulsar un botón. Para otros casos, necesitaremos redactar funciones que nos asistan. Por ejemplo, podemos crear una tetera con un clic de ratón o ejecutando la función Teapot(), que viene de serie en 3DSMAX. Pero en cambio si quiero crear el esqueleto de un personaje y elegir todas sus características no puedo hacerlo con un click de ratón. Pero sí podría llegar a hacerlo con una función construyePersonaje(), que a su vez llamara a otras llamadas construyeTronco(), construyePiernas(), etc.

Es decir, podría programar esos comportamientos o comandos. Esta manera 'no-lineal' de pensar se va adquiriendo a medida que vamos desarrollando pequeñas herramientas, y lo ideal para soltarse programando es... programar. Así que si después de leer este documento tienes ganas de más, piensa qué partes de tu trabajo resultan repetitivas y pueden ser optimizadas y lánzate a programar para automatizarlas y hacerlas menos tediosas.

Y antes de empezar a comentar el código, un apunte más: una de las cosas más útiles que podemos hacer cuando programamos es usar la ayuda de MAXScript, que MAX instala junto al resto de archivos de ayuda.

Hay capítulos que detallan lo básico; hay otros que, de modo similar a este documento, analizan y comentan la creación de una herramienta para resolver o asistir en tal o cual problema. Y por último tenemos capítulos que describen todas las funciones, comandos y propiedades de cada una de las partes de 3DSMAX a las que podemos acceder: cámaras, modificadores, geometría, controladores... Junto al MacroRecorder, que nos 'chiva' la correspondencia de la mayoría de comandos que ejecutamos con su equivalente en código, la ayuda de MAXScript será nuestro compañero de viaje siempre que programemos.

El ‘script’ completo lo puedes encontrar aquí

Vamos a ir parte por parte hablando de las distintas secciones que componen el código.

CABECERA


/*
__MXSDOC__
[TITLE]
MorphConnector
[DESCRIPTION]
Connects the morpher channels of an object to a joystick
[USAGE]
--
[CREATION INFO]
Author:Iker J. de los Mozos
[Category]
Rigging
[KEYWORDS]
Rigging, facial
[VERSION HISTORY]
V1.0 -- First released version.
[SEE ALSO]
__END__
*/

Es un trozo de código que sirve como introducción al script.

Normalmente contiene información como el nombre del autor, el título del programa, fecha de revisiones, número de versión... Vamos, todo lo que creamos que pueda ser útil.

Cuando englobamos todo ese código entre los símbolos /* y */, decimos que ese código está comentado, porque 3DSMAX no lo ejecuta (no busca comandos u órdenes dentro de él) sino que salta esa parte.

Es muy útil para hacer pequeñas anotaciones o, como en este caso, añadir información relevante. También se pueden comentar líneas enteras o parte de ellas usando dos guiones (--).


Después de atar los controles a las expresiones faciales del personaje, crear reacciones como ésta es más sencillo e intuitivo para el animador.

INTERFAZ 

if morphConnector != undefined do(destroyDialog morphConnector)
La última línea del script crea un cuadro de diálogo llamado morphConnector, que es la interfaz de la herramienta completamente operativa.

Con esta sencilla línea, hacemos que si la ventana ya existe (es decir, si la variable morphConnector ya existe, será distinta de undefined) la cerremos para crear una nueva. Sólo vamos a querer una instancia de esta herramienta abierta, así que destruímos la que ya existe si y sólo si existe previamente. Otra manera un poco más elegante de hacerlo es mediante el uso de try() y catch():

try (destroyDialog morphConnector) catch ()
En este caso, MAX intenta eliminar la ventana, y no se preocupa de chequear si existe previamente o no, como hacemos con la sentencia (if...). Si la orden ‘destroyDialog morphConnector’ da error (por ejemplo, porque aún no ha sido creada la ventana) se ejecutaría lo que englobara el comando catch.

Al no poner nada entre paréntesis... ¿lo adivinas? Efectivamente, no pasa nada. Si hubiésemos escrito algo como messageBox (“Esta ventana no existe”) entre los paréntesis de catch(), ésa sería la orden que se ejecutara cuando  try (destroyDialog morphConnector) diera error.

VARIABLES LOCALES

local selObj -- the selected object
local theChannel  -- the active channel
local theJoys -- the active joystick
local theIdx = #()-- array with the index of the channels
local theChannels = #()-- array with the names of the channels
Definir las variables que vayamos a necesitar al principio del código suele ser una buena práctica. De ese modo, nos aseguramos que pueden ser accesibles desde cualquier función y desde cualquier parte del programa. No es necesario decirle a 3DSMAX qué valor vamos a darle a cada una de esas variables. En el caso de que la variable sea un array, necesitamos definirla como tal (mediante el uso de #()), ya que creamos esa lista pero vacía y más adelante la llenamos de los datos necesarios para realizar las conexiones.

MAXScript Editor mostrando el código del script.

FUNCIONES

A partir de ahí, y todos los párrafos que empiezan por fn, empezamos a definir funciones, que son pequeños programas independientes para realizar ciertas acciones concretas.

Como decía antes, partimos de una gran acción general (conectar un joystick con varios morphs faciales) y necesitamos ir desglosándola en pequeñas partes, para establecer paso a paso qué es lo que hay que hacer.

No existe ningún comando en 3DSMAX que equivalga a ‘conéctame esto a esto otro’, así que escribiremos el manual de instrucciones necesario para conseguirlo. Pequeñas funciones como ‘oye, mírame cuántos canales activos tiene el modificador ‘Morpher’’ o ‘crea un Expression Controller en cada una de esas pistas con esta expresión que te voy a dar’ son los engranajes que harán que nuestro programa, al final, ejecute acciones complejas con tan sólo pulsar un botón.

fn listTargets obj =-- this function lists all the morph targets for the selected object
   (
       theChannels = #()
       if obj.modifiers[#Morpher]!= undefined then
       (
           theMod = obj.modifiers[#Morpher]        
           for i =1 to 100do
           (
               if WM3_MC_HasData theMod i then -- checks if the track has a morph target
               (
                   --WM3_MC_GetName theMod i
                   append theIdx i -- appends the track to the array of channels
                append theChannels (WM3_MC_GetName theMod i)-- appends the track to the array of names
               )
           )
       )      
       else
       (
           messageBox "This object does not have a Morpher modifier"
           theChannels = #()
       )
   )   

 Esta función añade el índice y el nombre de cada pista del modificador ‘Morph’ a dos listas que habíamos declarado al principio del código. Esto lo hacemos porque en la interfaz permitiremos al usuario que elija qué pista quiere automatizar, de modo que el morph correspondiente no sea controlado manualmente mediante el spinner sino que sea el joystick el que maneje su intensidad.

Además, añadimos alguna línea de ‘error checking’, para que el script no lance un error cuando no se cumpla alguna de las condiciones.

Por ejemplo, si no comprobásemos primero si el objeto en cuestión tiene el modificador Morpher en su pila, las siguientes líneas, al buscar información dentro de este modificador detendrían la ejecución si el modificador no existe. Hacer chequeo de errores es necesario, y se convierte en imprescindible cuando el usuario tiene cierta libertad de elección dentro del programa. De ese modo nos aseguraremos de que la herramienta funcione como nosotros queremos que lo haga ;-)

La siguiente función es la que se encarga de crear las conexiones entre ‘joysticks’ y pistas de ‘Morpher’. Voy a desglosarla para que sea más fácil su comprensión:

fn connectChannel theButton theShape index =-- this function connects the morpher with the joysticks
     
Una función es un trozo de código genérico y reutilizable. Y eso es, en parte, por los argumentos de entrada que tiene. Cada argumento es como un comodín que nos permite operar con variables genéricas. Es decir:


fn sumaNumeros =
(
print (2 + 3)
)

 Al ejecutar sumaNumeros(), el resultado siempre será 5. Pero si permitimos cierta interacción:


fn sumaNumeros a b =
(
print (a + b)
)

… acabamos de crear una función genérica que nos permite sumar los números que nosotros queramos, escribiendo simplemente sumaNumeros 45 712, por ejemplo. Ésa es una muestra de la potencia y versatilidad de una función.

Inmediatamente después, recuperamos algunos de los valores que tenemos almacenados en nuestras listas para poder operar con ellos. Recuerda que una función por sí sola no hará nada, sino que necesitamos los datos que otras funciones devuelven, como si de una cadena de montaje se tratara.

theVal = findItem theChannels theButton.text -- looks for the name of the channel, and returns its position on the array
theChannel = theIdx[theVal]-- converts the position to a morph channel

Esta parte es un poco compleja de explicar. El script está hecho para que el usuario elija un morph de la lista y lo asocie a una posición del joystick. Para ello, una vez está ese morph resaltado, el usuario tiene que pulsar en uno de los 8 botones que marcan esas posiciones. Acto seguido, el texto mostrado por el botón cambia, y reproduce el nombre del canal.

Bien, esa primera línea busca el texto del botón dentro de la lista que contiene el nombre de todos los canales, y devuelve el índice en caso de encontrarlo. Es decir, theVal es igual a un número.

La segunda línea es un ‘seguro’ en caso de que nuestros morphs no sean consecutivos. Es decir, en caso de que tengamos pistas vacías entre las que sí tienen un morph.


selObj.modifiers[#Morpher][theChannel].controller = Float_Expression()-- assigns the controller to the channel

Ésta es fácil: asigna un Expression Controller a la pista de Morpher correspondiente.


case index of
       (          
           1:(
               selObj.modifiers[#Morpher][theChannel].controller.addScalarTarget "cntX" theShape.pos.controller[1].controller
               selObj.modifiers[#Morpher][theChannel].controller.addScalarTarget "cntY" theShape.pos.controller[2].controller
               selObj.modifiers[#Morpher][theChannel].controller.addScalarConstant "limit" theShape.pos.controller[2].controller.upper_limit
               selObj.modifiers[#Morpher][theChannel].controller.setExpression "if (cntX<0,(cntY/limit)*100,(((cntY/limit)-((1/limit)*cntX+1))*100)+100)"                  

               )
         
           2:(
               selObj.modifiers[#Morpher][theChannel].controller.addScalarTarget "cnt" theShape.pos.controller[2].controller
               selObj.modifiers[#Morpher][theChannel].controller.addScalarConstant "limit" theShape.pos.controller[2].controller.upper_limit
               selObj.modifiers[#Morpher][theChannel].controller.setExpression "(100/limit)*cnt"
               )


Uno de los argumentos que la función connectChannels nos pide es ‘index’. Este número será el identificador de cada botón. Si miras a la interfaz, de arriba a abajo y de izquierda a derecha, estos números serán 1,2 y 3; 4 y 6; 7, 8 y 9. El 5 lo reservamos para el botón que selecciona el joystick... y porque no queremos lanzar ninguna expresión facial cuando el control está en su posición por defecto.

Las expresiones matemáticas asociadas a cada posición del joystick son ligeramente distintas. De ahí que tengamos que desglosar y describir cada uno de los casos. Ese ‘case... of’ es una condición, de manera que si el índice es igual a 1, ejecutará el código asociado a esa opción; si es 2, lo mismo, y así sucesivamente.

Así, cada una de las opciones consta de las mismas órdenes: creamos dos variables dentro de ese controlador de expresión y escribimos la fórmula que disparará el ‘morph’ correspondiente.

fn disconnectChannel theItem =
(
  theVal = findItem theChannels theItem
  theChannel = theIdx[theVal]
  selObj.modifiers[#Morpher][theChannel].controller = Bezier_Float()
  selObj.modifiers[#Morpher][theChannel].controller.value = 0
)
 Esta función desconecta la pista de morph seleccionada. Es útil en caso de que queramos ‘liberarla’ de su control, pero no es necesario hacer este paso si queremos volver a conectarla, ya que una conexión nueva sobreescribe la anterior.


fn disconnectJoys =
(
   theArray = theJoys.morphData.morphIndex
   for i =1 to theArray.count do
   (
       selObj.modifiers[#Morpher][theArray[i]].controller = Bezier_Float()
       selObj.modifiers[#Morpher][theArray[i]].controller.value =0
   )
)

 Anteriormente, en el código habíamos hecho referencia a ese parámetro llamado morphData, que no es otra cosa que un custom attribute que usamos para que cada control almacene internamente qué morphs está disparando.

Cada entidad en MAX (objeto, modificador, controlador, …) tiene unas propiedades determinadas. Haciendo esto, añadimos nosotros las propiedades que nos interesen. En este caso, ese atributo implica que desconectar el joystick es tan sencillo como leer los índices de esos morphs (ésa es la información que almacenamos en el atributo), y sobreescribir el controlador de expresión por uno ‘Bezier Float’.


fn disconnectAll =
(
  for i =1 to theIdx.count do
  (
      theChannel = theIdx[i]
      selObj.modifiers[#Morpher][theChannel].controller = Bezier_Float()
      selObj.modifiers[#Morpher][theChannel].controller.value =0
  )
)

 Esta función nos permitirá romper la conexión entre todos los morphs y sus correspondientes joysticks. En lugar de ver qué morphs tiene asociado cada control, usamos un loop para asignar un controlador Bezier Float a cada una de las pistas del modificador Morpher.


fn getIndexFromMorph MorphChannel =
(
   theVal = findItem theChannels MorphChannel -- looks for the name of the channel, and returns its position on the array
   theChannel = theIdx[theVal]-- converts the position to a morph channel
   return theChannel
--         selObj.modifiers[#Morpher][theChannel].controller = Float_Expression() -- assigns the controller to the channel  
)

fn connectCorrective fixShape m1Shape m2Shape =-- this function connects the morpher with the joysticks
(  
   fixShape_idx = getIndexFromMorph fixShape
   m1_idx = getIndexFromMorph m1Shape
   m2_idx = getIndexFromMorph m2Shape
--     theChannel = theIdx[theVal] -- converts the position to a morph channel
     
     
   selObj.modifiers[#Morpher][fixShape_idx].controller = Float_Expression()-- assigns the controller to the channel      
   selObj.modifiers[#Morpher][fixShape_idx].controller.addScalarTarget "exp1" selObj.modifiers[#Morpher][m1_idx].controller
   selObj.modifiers[#Morpher][fixShape_idx].controller.addScalarTarget "exp2" selObj.modifiers[#Morpher][m2_idx].controller
   selObj.modifiers[#Morpher][fixShape_idx].controller.setExpression "if ((exp1 < 0 & exp2 < 0), 0, (exp1*exp2)/100)"              
)

Las dos funciones anteriores sirven para conectar morphs correctores. Se hace necesario, en ocasiones, lanzar automáticamente un tercer morph cuando la combinación de otros dos no quedan todo lo bien que debería, o bien para añadir detalle adicional.

Por ejemplo, en la combinación de subir la comisura de un lado del labio más moverla hacia afuera: si modelamos un correction shape que eleve un poco la carne que cubre el pómulo y lo conectamos a estos dos morphs podemos darle más calidad y naturalidad a esa expresión facial.

MacroRecorder nos 'chiva' la traducción a MAXScript de casi todas las órdenes que ejecutamos en 3D Studio MAX

INTERFAZ


Con las siguientes líneas, construímos la interfaz gráfica que verá el usuario. Hasta ahora nos hemos ocupado de los entresijos del script. Ya tenemos todas el código necesario para poder hacer lo que queríamos hacer: conectar automáticamente una pista de Morpher a un joystick. Con la interfaz permitimos al usuario interactuar y ejecutar todas esas funciones que hemos creado.

Dentro de este apartado conviene distinguir entre la propia interfaz, compuesta por elementos como botones, menús, persianas, … y los eventos asociados a esos elementos.

ELEMENTOS DE LA INTERFAZ


rollout morphConnector "morphConnector 1.0" width:184 height:640
(  
   pickButton object_pck "Pick an object" pos:[16,30] width:152 height:32
   comboBox cbx1 "" pos:[16,72] width:152 height:14
   groupBox joys_grp " Joystick Mapping" pos:[8,280] width:168 height:174
   groupBox morph_grp " Morphs" pos:[8,8] width:168 height:268
   button joys_CDo "..." pos:[72,400] width:40 height:40
   button joys_CUp "..." pos:[72,304] width:40 height:40
   button joys_RUp "..." pos:[16,304] width:40 height:40
   button joys_RMid "..." pos:[16,352] width:40 height:40
   button joys_RDo "..." pos:[16,400] width:40 height:40
   button joys_LUp "..." pos:[128,304] width:40 height:40
   button joys_LMid "..." pos:[128,352] width:40 height:40
   button joys_LDo "..." pos:[128,400] width:40 height:40
   button connect_btn "Connect" pos:[8,464] width:168 height:40
   button disconnect_btn "Disc. Channel" pos:[8,552] width:80 height:40
   button disconnectJoy_btn "Disc. Joystick" pos:[96,552] width:80 height:40
   button flush_btn "Flush Connections" pos:[8,608] width:168 height:24
   button fixTool_btn "Corrective Shape Tool" pos:[8,512] width:168 height:32
   pickButton joys_pck "- J -" pos:[72,352] width:40 height:40


Para construir la interfaz de un script podemos apoyarnos en Visual MAXScript Editor, que nos ayuda a diseñar cómo se va a presentar de un modo mucho más intuitivo. Reescalar elementos, posicionarlos, nombrarlos... es mucho más sencillo desde el editor. Incluso podemos activar los eventos asociados a cada elemento de la interfaz.

Una vez estamos satisfechos, podemos exportar ese diseño al formato nativo de MAXScript, .ms. Luego, lo abrimos, y seguimos completándolo.

EVENTOS

Cuando tenemos todos los engranajes a un lado, y la ‘carcasa’ del programa por otro, lo único que resta es enlazar a ambas cosas. Cada elemento de la interfaz gráfica tiene una serie de eventos asociados: por ejemplo, hacer click con el botón izquierdo del ratón sobre un botón. Esa acción, el click izquierdo, es el evento.


on flush_btn pressed do
(
disconnectAll()
)

El formato tradicional de los eventos sigue el siguiente patrón:


on nombreElemento accióndo (lo que queremos ejecutar)
Así que es fácil deducir que al pulsar el botón cuyo nombre interno es flush_btn se ejecutará la función disconnectAll(), que romperá la conexión entre las pistas del modificador Morpher y los controles faciales.


on object_pck picked obj do
(
  if obj != undefined do
  (  
       cbx1.items = #()
       object_pck.text = obj.name
       selObj = object_pck.object
       listTargets selObj
       cbx1.items = theChannels
   )
)

Éste es el evento correspondiente al pick button. Al pulsarlo una vez, nos pide que seleccionemos un objeto. En este caso particular, queremos pulsar sobre la cabeza de un personaje, que es el objeto que contiene los morphs. Al seleccionar un objeto (observa la condición ‘si lo seleccionado no es indefinido’, es decir, si hemos seleccionado algo) se ejecuta el código de dentro:

  • Vaciamos el comboBox (caja que contiene una lista de elementos) llamado cbx1.
  • Cambiamos la etiqueta (el texto que se muestra) del pick button por el nombre del objeto seleccionado.
  • Asignamos el objeto seleccionado a la variable selObj.
  • Ejecutamos la función listTargets que escribimos con anterioridad, que nos devuelve todas las pistas del modificador Morpher.
  • Rellenamos la lista de la comboBox con los elementos de la lista que acabamos de obtener.
on joys_pck picked obj do
(
   if obj != undefined do
   (          
        joys_pck.text = obj.name
        joys_pck.tooltip = obj.name
        theJoys = joys_pck.object
        connect_btn.enabled = true
    )
)


Este botón nos permite elegir el joystick al cual conectaremos las distintas pistas. Cambiamos varias propiedades del botón para que se reflejen en la interfaz.


on joys_RUp pressed do
(
   if joys_RUp.text =="..." then
   (
       joys_RUp.text = cbx1.text
       joys_RUp.tooltip = cbx1.text
   )
   else
   (
       joys_RUp.text ="..."
       joys_RUp.tooltip ="..."
   )
)
El resto de botones alrededor del selector de joystick ejecutan la misma lógica: si el texto del botón es “...”, entonces sustituye el texto y el cuadro flotante de información (el que sale al posar el puntero varios segundos encima) del botón por el nombre de la pista de morph que tengamos seleccionada en la lista superior. Y si el texto es distinto a eso, entonces haz que vuelva a ser “...”. De esa manera, tenemos un toggle con el que podemos alternar entre ‘ocupado’ y ‘vacío’, útil por si nos equivocamos al asignar pistas.


on connect_btn pressed do
(
      try (connectChannel joys_RUp theJoys 1) catch()
      try (connectChannel joys_CUp theJoys 2) catch()
      try (connectChannel joys_LUp theJoys 3) catch()
      try (connectChannel joys_RMid theJoys 4) catch()
      try (connectChannel joys_CMid theJoys 5) catch()
      try (connectChannel joys_LMid theJoys 6) catch()
      try (connectChannel joys_RDo theJoys 7) catch()
      try (connectChannel joys_CDo theJoys 8) catch()
      try (connectChannel joys_LDo theJoys 9) catch()
)

Al pulsar el botón de conectar, ejecuta la función que escribimos antes para cada uno de los morphs asignados a un botón. No tiene mayor secreto :-)


on disconnect_btn pressed do
(
      disconnectChannel cbx1.text
)

Como ya habréis adivinado, desconecta la pista de morph seleccionada.


on disconnectJoy_btn pressed do
(
      if theJoys != undefined do
      (
             disconnectJoys()
             theJoys.morphData.morphIndex = #()
      )
)
Desconecta todas las pistas asociadas a un joystick. Observa cómo la función sólo se ejecuta si la variable theJoys (definida en un evento anterior) tiene un valor distinto a ‘no definido’. Vacía también la lista de morphs asociada a un control.


on fixTool_btn pressed do
(
      createDialog CorrectiveShapeTool 258194
         
      CorrectiveShapeTool.fix_ddl.items = TheChannels
      CorrectiveShapeTool.morph1_ddl.items = TheChannels
      CorrectiveShapeTool.morph2_ddl.items = TheChannels          
)
Al presionar este botón se abre una pequeña ventana flotante, que nos permite atar un morph (idealmente, un correction shape) a otros dos, de manera que el primero se lance automáticamente cuando esos dos estén activos, como describimos hace unos párrafos. La conexión se crea al pulsar el botón ‘Aceptar’.


createDialog morphConnector 186642
Esta última línea es la que se encarga de crear la ventana flotante con las dimensiones especificadas.

CONCLUSIÓN


En este artículo hemos descrito paso a paso el funcionamiento de una herramienta compleja, que automatiza tareas y que le permite al usuario configurar ciertos comportamientos. Evidentemente, tiene mucho margen de mejora y algunas partes se pueden optimizar para que sean menos enrevesadas. Espero que este documento os haya servido para entender un poquito mejor cómo funciona un script y qué ventajas puede aportarnos en la construcción de personajes.

Y recordad que si os ha picado el gusanillo, ¡lo mejor es ponerse manos a la obra y programar!


Iker de los Mozos
Character TD en Walt Disney Animation Studios, The Mill y Supervisor de Rigging en Kandor Graphics
























Tags :
Publicar un comentario