Creación de un compositor o editor de imágenes personalizado con Vue.js

- Andrés Cruz - EN In english

Video thumbnail

Te voy a mostrar cómo construir un sencillo editor o compositor de imágenes personalizado. El objetivo principal de esta herramienta es mantener una línea gráfica coherente para las miniaturas y contenidos visuales de marca: fondos limpios (generalmente blancos), textos con alto contraste, contenedores con degradados, fragmentos tipo brush (pinceladas) y logos representativos.

Aunque es posible utilizar software de edición tradicional como GIMP, contar con una herramienta web preconfigurada agiliza enormemente el flujo de trabajo. Esta utilidad forma parte de un ecosistema más amplio tipo "cerebro digital", donde se integran asistentes para la generación de plantillas de video, estructuras de hyperframes y automatización de recursos multimedia.

De scripts estáticos en Python a interfaces dinámicas con View y HTML5

Inicialmente, la generación de estas imágenes la realizaba mediante un script en Python utilizando librerías de procesamiento visual. La idea era automatizar la creación del lienzo, colocar el texto dinámicamente y superponer los logos.

def generate_clean_text_thumbnail(
    raw_text: str,
    brush_text: str = "",
    brush_color: str = "#E52B20",
    brush_text_color: str = "#0F0F0F",
    border_color: str = "#5A00E0",
    logo_path: str | None = None,
    start_margin_y: int = 120,
    right_half_start_x: int = 960,
    logo_max_width: int = 1300,
    logo_max_height: int = 1300,
    logo_resize_width: int = 900,
    font_size: int = 260,
    word_spacing: int = 10,
    line_spacing: int = 0,
    show_opening_quote: bool = True,
    show_closing_quote: bool = True,
) -> str:
    """Genera la miniatura posicionando un logo en la mitad derecha y el texto
    superpuesto sobre él en el lado izquierdo."""

    # --------------------------------------------------------------------------
    # ⚙️ CONSTANTES Y PARÁMETROS DE DISEÑO
    # --------------------------------------------------------------------------
    canvas_width = 1920
    canvas_height = 1080

    # Márgenes del lienzo
    outer_border_margin = 45

    # ️ Parámetros del Logo (Mitad Derecha)

    # Tipografía
    font_path = "app/static/fonts/SUSE-ExtraBold.ttf"

    # Badges y Espaciados
    badge_padding_x = 25
    badge_padding_y = 12
    badge_corner_radius = 12

    line_rotation_angle = 3  # inclinacion del texto

    output_dir = "app/static/outputs"

    # --------------------------------------------------------------------------
    #  PROCESAMIENTO
    # --------------------------------------------------------------------------
    # 1. Crear el lienzo base con el marco de color
    canvas = Image.new("RGBA", (canvas_width, canvas_height), border_color)
    draw = ImageDraw.Draw(canvas)

    # 2. Dibujar el lienzo blanco interior
    draw.rectangle(
        [
            outer_border_margin,
            outer_border_margin,
            canvas_width - outer_border_margin,
            canvas_height - outer_border_margin,
        ],
        fill=(255, 255, 255, 255),
    )

    # --- Uso en tu función principal ---
    # Generas el destello del color que desees (ejemplo: rojo o amarillo)
    # burst = draw_emphasis_burst(color="#E52B20")

    # Lo pegas en la esquina superior izquierda de la caja principal
    # canvas.paste(burst, (start_margin_x - 80, start_margin_y - 100), burst)

    # --------------------------------------------------------------------------
    # ️ 3. PEGAR EL LOGO EN LA MITAD DERECHA (Debajo del texto)
    # --------------------------------------------------------------------------

    logo_file = f"app/static/imgs/logos/{logo_path.lower()}.png" if logo_path else None

    if logo_file and os.path.exists(logo_file):
        try:
            logo_img = Image.open(logo_file).convert("RGBA")

            if logo_resize_width > 0:
                # Modo manual: ancho fijo, altura proporcional. Autoritativo,
                # no se re-escala después (el usuario controla el tamaño).
                ratio = logo_resize_width / logo_img.width
                logo_resize_height = max(1, int(logo_img.height * ratio))
                logo_img = logo_img.resize(
                    (logo_resize_width, logo_resize_height), Image.Resampling.LANCZOS
                )
            else:
                # Modo automático: fit al área real de la mitad derecha
                logo_img.thumbnail((logo_max_width, logo_max_height), Image.Resampling.LANCZOS)
                right_area_width = canvas_width - right_half_start_x - outer_border_margin
                right_area_height = canvas_height - (outer_border_margin * 2)
                if logo_img.width > right_area_width or logo_img.height > right_area_height:
                    logo_img.thumbnail(
                        (right_area_width, right_area_height), Image.Resampling.LANCZOS
                    )

            right_area_width = canvas_width - right_half_start_x - outer_border_margin
            right_area_height = canvas_height - (outer_border_margin * 2)

            logo_x = right_half_start_x + (right_area_width - logo_img.width) // 2
            logo_y = outer_border_margin + (right_area_height - logo_img.height) // 2

            # Pegar logo usando su propio canal alpha como máscara
            canvas.paste(logo_img, (logo_x, logo_y), logo_img)
        except Exception as e:
            print(f"Error cargando el logo {logo_file}: {e}")
            
            ***

Sin embargo, la aproximación con Python presentó serias limitaciones:

  • Falta de interactividad: Para ajustar el tamaño de una fuente, desplazar un elemento o corregir un salto de línea (\n), era necesario volver a ejecutar el script y generar un archivo físico en disco.
  • Complejidad en la maquetación: Calcular coordenadas fijas, rotaciones aleatorias, márgenes y alineaciones de texto en un lienzo estático resulta tedioso y poco eficiente.

Debido a esto, la solución fue migrar hacia un enfoque web utilizando HTML5, CSS3, JavaScript y Vue.js para gestionar la interactividad y el estado de la aplicación.

Arquitectura y tecnologías del compositor web

La versión web funciona como un compositor interactivo sobre un Canvas/DOM, ofreciendo control total sobre las capas de diseño (layers). El stack utilizado se compone de:

  • Vue.js: Para la gestión reactiva del estado, propiedades del lienzo, listado de capas e interactividad.
  • Tailwind CSS: Para el maquetado general de la interfaz y la aplicación de filtros visuales nativos (brillo, contraste, sombras).
  • Interact.js: Para implementar de forma rápida y fluida las funcionalidades de arrastrar y soltar (drag and drop) y la redistribución de elementos.
  • html2canvas / html-to-image: Para renderizar la composición gráfica y exportar el resultado final directamente a un archivo de imagen (PNG o JPEG).

Ventajas del uso de archivos SVG dinámicos

Una de las mayores ventajas de utilizar el stack web frente a imágenes matriciales en Python es la manipulación de vectores SVG.

Al cargar un archivo SVG (como pinceladas o marcos de texto), no se enlaza simplemente como una imagen estática, sino que se decodifica su contenido XML interno. Esto permite cambiar el color de relleno (fill) o del trazo (stroke) de forma dinámica desde la interfaz utilizando un único archivo base, sin necesidad de almacenar múltiples versiones del mismo recurso.

El HTML:

  <div id="app" class="flex h-[calc(100vh-6rem)]">
    
    <!-- ── PANEL LATERAL ─────────────────────────────────────────── -->
    <aside class="w-80 bg-gray-800 p-4 flex flex-col gap-4 border-r border-gray-700 overflow-y-auto">
      <h2 class="text-xl font-bold">Editor Canvas (CDN)</h2>

      <!-- Botones de Acción -->
      <div class="flex gap-2">
        <button @click="addTextLayer" class="flex-1 bg-blue-600 hover:bg-blue-500 py-2 rounded text-sm font-semibold">
          + Texto Contenedor
        </button>
        <button @click="addNormalTextLayer" class="flex-1 bg-cyan-600 hover:bg-cyan-500 py-2 rounded text-sm font-semibold">
          + Texto
        </button>
      </div>
      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Texto Brush (estilo de pincelada)</label>
        <div class="flex gap-2">
          <select v-model="brushSelection" class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm">
            <option value="">— Seleccionar —</option>
            <option v-for="file in brushMasks" :key="file" :value="file">{{ file }}</option>
          </select>
          <button @click="addBrushTextLayer" class="bg-orange-600 hover:bg-orange-500 px-3 py-1 rounded text-sm font-semibold whitespace-nowrap">
            + Crear
          </button>
        </div>
      </div>
      <div class="flex gap-2">
        <label class="flex-1 bg-green-600 hover:bg-green-500 py-2 rounded text-sm font-semibold text-center cursor-pointer">
          + Imagen
          <input type="file" accept="image/*" class="hidden" @change="handleImageUpload" />
        </label>
      </div>

      <!-- Agregar imágenes del proyecto -->
      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Agregar Logo (proyecto)</label>
        <select v-model="selectedLogo" @change="addProjectImage('logos', selectedLogo, 300)"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option value="">— Seleccionar logo —</option>
          <option v-for="f in logoFiles" :key="f" :value="f">{{ f }}</option>
        </select>
      </div>

      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Agregar Fragmento (proyecto)</label>
        <select v-model="selectedFragment" @change="addProjectImage('fragments', selectedFragment, 600)"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option value="">— Seleccionar fragmento —</option>
          <option v-for="f in fragmentFiles" :key="f" :value="f">{{ f }}</option>
        </select>
      </div>

      <!-- Formato de Lienzo -->
      <div class="border-t border-gray-700 pt-3">
        <label class="block text-xs text-gray-400 mb-1">Formato de Lienzo</label>
        <select v-model="selectedFormat" @change="handleFormatChange"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option v-for="fmt in canvasFormats" :key="fmt.id" :value="fmt.id">{{ fmt.label }}</option>
        </select>
      </div>

      <!-- Color de Fondo -->
      <div class="border-t border-gray-700 pt-3">
        <label class="block text-xs text-gray-400 mb-1">Color de Fondo del Lienzo</label>
        <input type="color" v-model="canvasConfig.bgColor" class="w-full h-8 cursor-pointer rounded bg-transparent" />
      </div>

      <!-- Borde -->
      <div class="border-t border-gray-700 pt-3 flex items-center justify-between">
        <label for="border-toggle" class="text-xs text-gray-400">Borde (#5A00E0, 80px)</label>
        <input id="border-toggle" type="checkbox" v-model="canvasConfig.showBorder" class="w-5 h-5" />
      </div>

      <!-- Controles de Capa Seleccionada -->
      <div v-if="selectedLayer" class="border-t border-gray-700 pt-3 flex flex-col gap-3">
        <h3 class="text-sm font-semibold text-gray-300">Propiedades de Capa</h3>

        <template v-if="selectedLayer.type === 'text'">
          <div>
            <label class="block text-xs text-gray-400">Texto</label>
            <input v-model="selectedLayer.text" type="text" class="w-full bg-gray-700 px-2 py-1 rounded text-sm" />
          </div>

          <div class="flex gap-2">
            <div class="flex-1">
              <label class="block text-xs text-gray-400">Color Texto</label>
              <input type="color" v-model="selectedLayer.color" class="w-full h-8" />
            </div>
            <div class="flex-1">
              <label class="block text-xs text-gray-400">Fondo/Banner</label>
              <input type="color" v-model="selectedLayer.bgColor" class="w-full h-8" />
            </div>
          </div>

          <div>
            <label class="block text-xs text-gray-400">Tipografía</label>
            <select v-model="selectedLayer.fontFamily"
              class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
              <option v-for="fam in fontFamilies" :key="fam" :value="fam">{{ fam }}</option>
            </select>
          </div>

          <div>
            <label class="block text-xs text-gray-400">Tamaño Fuente ({{ selectedLayer.fontSize }}px)</label>
            <input type="range" min="120" max="350" v-model.number="selectedLayer.fontSize" class="w-full" />
          </div>

          <div class="flex items-center justify-between">
            <label for="italic-toggle" class="text-xs text-gray-400">Cursiva</label>
            <input id="italic-toggle" type="checkbox" v-model="selectedLayer.italic" class="w-5 h-5" />
          </div>

          <div class="flex items-center justify-between">
            <label for="outline-toggle" class="text-xs text-gray-400">Contorno de palabras</label>
            <input id="outline-toggle" type="checkbox" v-model="selectedLayer.outlineEnabled" class="w-5 h-5" />
          </div>

          <div v-if="selectedLayer.outlineEnabled">
            <label class="block text-xs text-gray-400">Color del contorno</label>
            <input type="color" v-model="selectedLayer.outlineColor" class="w-full h-8" />
          </div>

          <div v-if="selectedLayer.brush">
            <label class="block text-xs text-gray-400">Estilo de pincelada</label>
            <select v-model="selectedLayer.brushMask"
              class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
              <option v-for="file in brushMasks" :key="file" :value="file">{{ file }}</option>
            </select>
          </div>
        </template>

        <!-- Filtros y Efectos de la Capa -->
        <div class="border-t border-gray-700 pt-3 flex flex-col gap-3">
          <h3 class="text-sm font-semibold text-gray-300">Filtros y Efectos</h3>

          <!-- Sombra de caja -->
          <div class="flex items-center justify-between">
            <label for="fx-boxshadow-toggle" class="text-xs text-gray-400">Sombra de caja</label>
            <input id="fx-boxshadow-toggle" type="checkbox" v-model="selectedLayer.fx.boxShadow" class="w-5 h-5" />
          </div>
          <template v-if="selectedLayer.fx.boxShadow">
            <div class="flex gap-2">
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Color</label>
                <input type="color" v-model="selectedLayer.fx.shadowColor" class="w-full h-8" />
              </div>
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Desenfoque ({{ selectedLayer.fx.shadowBlur }}px)</label>
                <input type="range" min="0" max="80" v-model.number="selectedLayer.fx.shadowBlur" class="w-full" />
              </div>
            </div>
            <div>
              <label class="block text-xs text-gray-400">Desplazamiento ({{ selectedLayer.fx.shadowOffset }}px)</label>
              <input type="range" min="-40" max="40" v-model.number="selectedLayer.fx.shadowOffset" class="w-full" />
            </div>
          </template>

          <!-- Sombra de texto (solo texto) -->
          <template v-if="selectedLayer.type === 'text'">
            <div class="flex items-center justify-between">
              <label for="fx-textshadow-toggle" class="text-xs text-gray-400">Sombra de texto</label>
              <input id="fx-textshadow-toggle" type="checkbox" v-model="selectedLayer.fx.textShadow" class="w-5 h-5" />
            </div>
            <template v-if="selectedLayer.fx.textShadow">
              <div class="flex gap-2">
                <div class="flex-1">
                  <label class="block text-xs text-gray-400">Color</label>
                  <input type="color" v-model="selectedLayer.fx.textShadowColor" class="w-full h-8" />
                </div>
                <div class="flex-1">
                  <label class="block text-xs text-gray-400">Desenfoque ({{ selectedLayer.fx.textShadowBlur }}px)</label>
                  <input type="range" min="0" max="40" v-model.number="selectedLayer.fx.textShadowBlur" class="w-full" />
                </div>
              </div>
            </template>
          </template>

          <!-- Borde -->
          <div class="flex items-center justify-between">
            <label for="fx-border-toggle" class="text-xs text-gray-400">Borde</label>
            <input id="fx-border-toggle" type="checkbox" v-model="selectedLayer.fx.border" class="w-5 h-5" />
          </div>
          <template v-if="selectedLayer.fx.border">
            <div class="flex gap-2">
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Color</label>
                <input type="color" v-model="selectedLayer.fx.borderColor" class="w-full h-8" />
              </div>
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Grosor ({{ selectedLayer.fx.borderWidth }}px)</label>
                <input type="range" min="1" max="40" v-model.number="selectedLayer.fx.borderWidth" class="w-full" />
              </div>
            </div>
          </template>

          <!-- Esquinas redondeadas -->
          <div>
            <label class="block text-xs text-gray-400">Esquinas redondeadas ({{ selectedLayer.fx.borderRadius }}px)</label>
            <input type="range" min="0" max="120" v-model.number="selectedLayer.fx.borderRadius" class="w-full" />
          </div>

          <!-- Filtros CSS -->
          <div>
            <label class="block text-xs text-gray-400">Desenfoque ({{ selectedLayer.fx.blur }}px)</label>
            <input type="range" min="0" max="20" v-model.number="selectedLayer.fx.blur" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Brillo ({{ selectedLayer.fx.brightness }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.brightness" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Contraste ({{ selectedLayer.fx.contrast }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.contrast" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Saturación ({{ selectedLayer.fx.saturate }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.saturate" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Escala de grises ({{ selectedLayer.fx.grayscale }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.grayscale" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Sepia ({{ selectedLayer.fx.sepia }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.sepia" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Opacidad ({{ selectedLayer.fx.opacity }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.opacity" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Rotación de tono ({{ selectedLayer.fx.hueRotate }}°)</label>
            <input type="range" min="0" max="360" v-model.number="selectedLayer.fx.hueRotate" class="w-full" />
          </div>
        </div>

        <div>
          <label class="block text-xs text-gray-400">Rotación ({{ selectedLayer.rotation }}°)</label>
          <input type="range" min="-180" max="180" v-model.number="selectedLayer.rotation" class="w-full" />
        </div>

        <button @click="deleteSelectedLayer" class="bg-red-600 hover:bg-red-500 py-1 rounded text-xs">
          Eliminar Capa
        </button>
      </div>

      <!-- Guardar / Cargar Proyecto -->
      <div class="border-t border-gray-700 pt-3 flex flex-col gap-3">
        <h3 class="text-sm font-semibold text-gray-300">Guardar / Cargar Proyecto</h3>

        <div class="flex gap-2">
          <input v-model="projectFilename" type="text" placeholder="Nombre del proyecto"
            class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm" />
          <button @click="saveProjectToServer" class="bg-emerald-600 hover:bg-emerald-500 px-3 py-1 rounded text-xs font-semibold whitespace-nowrap">
            Guardar
          </button>
        </div>
        <button @click="downloadProjectJson" class="bg-gray-600 hover:bg-gray-500 py-1 rounded text-xs">
          Descargar JSON
        </button>

        <div class="flex flex-col gap-2">
          <label class="text-xs text-gray-400">Proyectos guardados ({{ jsonFiles.length }})</label>
          <div class="flex gap-2">
            <select v-model="selectedJsonFile" class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm">
              <option value="">— Seleccionar proyecto —</option>
              <option v-for="f in jsonFiles" :key="f" :value="f">{{ f }}</option>
            </select>
            <button @click="loadProjectFile(selectedJsonFile)" class="bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded text-xs font-semibold whitespace-nowrap">
              Cargar
            </button>
          </div>
        </div>

        <div class="flex flex-col gap-2">
          <label class="text-xs text-gray-400">Importar archivo local (.json)</label>
          <label class="bg-gray-600 hover:bg-gray-500 py-1 rounded text-xs text-center cursor-pointer">
            Seleccionar archivo
            <input type="file" accept=".json,application/json" class="hidden" @change="handleProjectUpload" />
          </label>
        </div>

        <div v-if="statusMsg" class="text-xs text-emerald-400 bg-gray-900 px-2 py-1 rounded">{{ statusMsg }}</div>
      </div>

      <button @click="exportImage" class="mt-auto bg-purple-600 hover:bg-purple-500 py-3 rounded font-bold">
        Exportar a PNG
      </button>
    </aside>

    <!-- ── LIENZO PRINCIPAL ──────────────────────────────────────── -->
    <main ref="stageRef" class="flex-1 relative overflow-hidden bg-gray-950 rounded-lg">
      <div class="absolute inset-0 flex items-center justify-center">
        <div
          class="relative"
          :style="{
            width: (canvasConfig.width * scale) + 'px',
            height: (canvasConfig.height * scale) + 'px'
          }"
        >
          <div
            ref="canvasRef"
            class="absolute top-0 left-0 shadow-2xl overflow-hidden"
            :style="{
              width: canvasConfig.width + 'px',
              height: canvasConfig.height + 'px',
              backgroundColor: canvasConfig.bgColor,
              transform: 'scale(' + scale + ')',
              transformOrigin: 'top left'
            }"
            @click.self="selectedLayerId = null"
          >
            <!-- Borde opcional (al fondo, los textos/imágenes van encima) -->
            <div
              v-if="canvasConfig.showBorder"
              class="absolute inset-0 pointer-events-none"
              :style="{ border: '80px solid #5A00E0' }"
            ></div>

            <!-- Capas Renderizadas -->
            <div
              v-for="layer in layers"
              :key="layer.id"
              :data-id="layer.id"
              class="draggable-layer absolute cursor-move flex items-center justify-center"
              :class="{ 'selected': selectedLayerId === layer.id && !isExporting }"
              :style="{
                left: layer.x + 'px',
                top: layer.y + 'px',
                width: layer.width + 'px',
                height: layer.height + 'px',
                transform: `rotate(${layer.rotation}deg)`,
                zIndex: layer.zIndex
              }"
              @click="selectLayer(layer.id)"
            >
              <!-- Contenido Texto -->
              <div
                v-if="layer.type === 'text'"
                class="w-full h-full flex items-center justify-center text-center p-2 font-bold select-none relative"
                :style="{
                  color: layer.color,
                  background: layer.brush
                    ? 'transparent'
                    : (layer.gradient
                      ? `linear-gradient(to bottom, ${layer.bgColor}, ${shadeColor(layer.bgColor, -35)})`
                      : layer.bgColor),
                  borderRadius: layer.brush ? '0px' : (layer.fx.borderRadius > 0
                    ? layer.fx.borderRadius + 'px'
                    : (layer.gradient ? '14px' : '0px')),
                  fontFamily: `'${layer.fontFamily}', sans-serif`,
                  fontSize: layer.fontSize + 'px',
                  fontStyle: layer.italic ? 'italic' : 'normal',
                  lineHeight: 1,
                  WebkitTextStroke: layer.outlineEnabled
                    ? Math.max(1, Math.round(layer.fontSize / 15)) + 'px ' + layer.outlineColor
                    : '0px',
                  boxShadow: layer.brush ? 'none' : layerBoxShadow(layer),
                  textShadow: layerTextShadow(layer),
                  border: layer.brush ? 'none' : layerBorder(layer),
                  filter: layerFilter(layer)
                }"
              >
                <!-- Banner con forma de pincelada -->
                <div
                  v-if="layer.brush"
                  class="absolute inset-0 pointer-events-none"
                  :style="{
                    backgroundColor: layer.bgColor,
                    borderRadius: layer.fx.borderRadius > 0 ? layer.fx.borderRadius + 'px' : '0px',
                    WebkitMaskImage: brushMaskUrl(layer),
                    maskImage: brushMaskUrl(layer),
                    WebkitMaskSize: '100% 100%',
                    maskSize: '100% 100%',
                    WebkitMaskRepeat: 'no-repeat',
                    maskRepeat: 'no-repeat',
                    WebkitMaskMode: 'alpha',
                    maskMode: 'alpha',
                    boxShadow: layerBoxShadow(layer),
                    border: layerBorder(layer)
                  }"
                ></div>
                <span class="relative">{{ layer.text }}</span>
              </div>

              <!-- Contenido Imagen -->
              <img
                v-else-if="layer.type === 'image'"
                :src="layer.src"
                class="w-full h-full object-contain pointer-events-none select-none"
                :style="{
                  boxShadow: layerBoxShadow(layer),
                  border: layerBorder(layer),
                  borderRadius: layer.fx.borderRadius + 'px',
                  filter: layerFilter(layer)
                }"
              />
            </div>
          </div>
        </div>
      </div>
    </main>

  </div>

El JavaScript en Vue.js:

<script>
  const { createApp, ref, reactive, computed, nextTick, onMounted } = Vue;

  createApp({
    setup() {
      const canvasRef = ref(null);
      const stageRef = ref(null);
      const isExporting = ref(false);
      const scale = ref(1);

      const canvasConfig = reactive({
        width: 1920,
        height: 1080,
        bgColor: '#ffffff',
        showBorder: true
      });

      const canvasFormats = ref([
        { id: 'landscape', label: 'Landscape (1920×1080)', width: 1920, height: 1080 },
        { id: 'vertical', label: 'Vertical (1080×1920)', width: 1080, height: 1920 }
      ]);
      const selectedFormat = ref('landscape');

      const updateScale = () => {
        if (!stageRef.value) return;
        const availW = stageRef.value.clientWidth;
        const availH = stageRef.value.clientHeight;
        scale.value = Math.min(
          availW / canvasConfig.width,
          availH / canvasConfig.height,
          1
        );
      };

      const handleFormatChange = () => {
        const fmt = canvasFormats.value.find(f => f.id === selectedFormat.value);
        if (!fmt) return;
        const fromW = canvasConfig.width;
        const fromH = canvasConfig.height;
        const toW = fmt.width;
        const toH = fmt.height;
        if (fromW === toW && fromH === toH) {
          updateScale();
          return;
        }
        canvasConfig.width = toW;
        canvasConfig.height = toH;
        // recalcula posiciones en el canvas, en la capa de layer al cambiar de formato
        layers.value.forEach((layer) => {
          layer.x = Math.round((layer.x / fromW) * toW);
          layer.y = Math.round((layer.y / fromH) * toH);
          // layer.width = Math.max(40, Math.round((layer.width / fromW) * toW));
          // layer.height = Math.max(20, Math.round((layer.height / fromH) * toH));
          if (layer.fontSize) {
            layer.fontSize = Math.max(24, Math.round((layer.fontSize / fromW) * toW));
          }
          if (layer.x + layer.width > toW) {
            layer.width = Math.max(40, toW - layer.x - 40);
          }
          if (layer.y + layer.height > toH) {
            layer.height = Math.max(20, toH - layer.y - 40);
          }
        });
        updateScale();
      };

      const layers = ref([]);
      const selectedLayerId = ref(null);
      const logoFiles = ref([]);
      const fragmentFiles = ref([]);
      const selectedLogo = ref('');
      const selectedFragment = ref('');
      const fontFamilies = ref([]);
      const jsonFiles = ref([]);
      const selectedJsonFile = ref('');
      const projectFilename = ref('');
      const statusMsg = ref('');
      const brushMasks = ref([]);
      const brushMaskUrls = ref({});
      const brushSelection = ref('2.svg');

      const fontFamilyName = (file) => file.replace(/\.(ttf|woff2)$/i, '');

      const shadeColor = (hex, percent) => {
        const num = parseInt(hex.replace('#', ''), 16);
        const amt = Math.round(2.55 * percent);
        const clamp = (v) => Math.max(0, Math.min(255, v));
        const r = clamp((num >> 16) + amt);
        const g = clamp(((num >> 8) & 0x00ff) + amt);
        const b = clamp((num & 0x0000ff) + amt);
        return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
      };

      const defaultFx = () => ({
        boxShadow: false,
        shadowColor: '#000000',
        shadowBlur: 20,
        shadowOffset: 0,
        textShadow: false,
        textShadowColor: '#000000',
        textShadowBlur: 10,
        border: false,
        borderWidth: 10,
        borderColor: '#ffffff',
        borderRadius: 0,
        blur: 0,
        brightness: 100,
        contrast: 100,
        saturate: 100,
        grayscale: 0,
        sepia: 0,
        opacity: 100,
        hueRotate: 0
      });

      const layerBoxShadow = (layer) => {
        const f = layer.fx;
        if (!f.boxShadow) return 'none';
        const o = f.shadowOffset;
        return `${o}px ${o}px ${f.shadowBlur}px ${f.shadowColor}`;
      };

      const layerTextShadow = (layer) => {
        const f = layer.fx;
        if (!f.textShadow) return 'none';
        return `0px 0px ${f.textShadowBlur}px ${f.textShadowColor}`;
      };

      const layerBorder = (layer) => {
        const f = layer.fx;
        return f.border ? `${f.borderWidth}px solid ${f.borderColor}` : 'none';
      };

      const layerFilter = (layer) => {
        const f = layer.fx;
        const parts = [];
        if (f.blur > 0) parts.push(`blur(${f.blur}px)`);
        if (f.brightness !== 100) parts.push(`brightness(${f.brightness}%)`);
        if (f.contrast !== 100) parts.push(`contrast(${f.contrast}%)`);
        if (f.saturate !== 100) parts.push(`saturate(${f.saturate}%)`);
        if (f.grayscale > 0) parts.push(`grayscale(${f.grayscale}%)`);
        if (f.sepia > 0) parts.push(`sepia(${f.sepia}%)`);
        if (f.opacity !== 100) parts.push(`opacity(${f.opacity}%)`);
        if (f.hueRotate !== 0) parts.push(`hue-rotate(${f.hueRotate}deg)`);
        return parts.join(' ') || 'none';
      };

      const injectFontFaces = (files) => {
        const style = document.createElement('style');
        style.textContent = files
          .map((f) => {
            const family = fontFamilyName(f);
            return `@font-face { font-family: '${family}'; src: url('/static/fonts/${encodeURIComponent(f)}'); }`;
          })
          .join('\n');
        document.head.appendChild(style);
      };

      const selectedLayer = computed(() => 
        layers.value.find(l => l.id === selectedLayerId.value)
      );

      // 1. Agregar capas
      const addTextLayer = () => {
        const newLayer = {
          id: Date.now(),
          type: 'text',
          fx: defaultFx(),
          brush: false,
          text: 'DESTACADO',
          x: 100,
          y: 100,
          width: 1200,
          height: 400,
          rotation: -3,
          color: '#ffffff',
          bgColor: '#eb1414',
          gradient: true,
          fontSize: 200,
          fontFamily: 'SUSE-SemiBold',
          italic: false,
          outlineEnabled: true,
          outlineColor: '#ffffff',
          zIndex: layers.value.length + 1
        };
        layers.value.push(newLayer);
        selectedLayerId.value = newLayer.id;
      };

      const addNormalTextLayer = () => {
        const newLayer = {
          id: Date.now(),
          type: 'text',
          fx: defaultFx(),
          brush: false,
          text: 'TEXTO',
          x: 100,
          y: 100,
          width: 400,
          height: 80,
          rotation: 0,
          color: '#0f172a',
          bgColor: 'transparent',
          gradient: false,
          fontSize: 160,
          fontFamily: 'SUSE-SemiBold',
          italic: false,
          outlineEnabled: true,
          outlineColor: '#ffffff',
          zIndex: layers.value.length + 1
        };
        layers.value.push(newLayer);
        selectedLayerId.value = newLayer.id;
      };

      const addBrushTextLayer = () => {
        const newLayer = {
          id: Date.now(),
          type: 'text',
          fx: defaultFx(),
          brush: true,
          brushMask: brushSelection.value || '2.svg',
          text: 'PINCELADA',
          x: 100,
          y: 100,
          width: 1600,
          height: 480,
          rotation: -3,
          color: '#ffffff',
          bgColor: '#000000',
          gradient: false,
          fontSize: 200,
          fontFamily: 'SUSE-SemiBold',
          italic: false,
          outlineEnabled: true,
          outlineColor: '#ffffff',
          zIndex: layers.value.length + 1
        };
        layers.value.push(newLayer);
        selectedLayerId.value = newLayer.id;
      };

      const handleImageUpload = (e) => {
        const file = e.target.files[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onload = (evt) => {
          const newLayer = {
            id: Date.now(),
            type: 'image',
            fx: defaultFx(),
            src: evt.target.result,
            x: 150,
            y: 150,
            width: 200,
            height: 200,
            rotation: 0,
            zIndex: layers.value.length + 1
          };
          layers.value.push(newLayer);
          selectedLayerId.value = newLayer.id;
        };
        reader.readAsDataURL(file);
      };

      const addProjectImage = (folder, filename, size) => {
        if (!filename) return;
        const newLayer = {
          id: Date.now(),
          type: 'image',
          fx: defaultFx(),
          src: '/static/imgs/' + folder + '/' + encodeURIComponent(filename),
          x: Math.round((canvasConfig.width - size) / 2),
          y: Math.round((canvasConfig.height - size) / 2),
          width: size,
          height: size,
          rotation: 0,
          zIndex: layers.value.length + 1
        };
        layers.value.push(newLayer);
        selectedLayerId.value = newLayer.id;
        if (folder === 'logos') {
          selectedLogo.value = '';
        } else {
          selectedFragment.value = '';
        }
      };

      const selectLayer = (id) => {
        selectedLayerId.value = id;
      };

      const deleteSelectedLayer = () => {
        layers.value = layers.value.filter(l => l.id !== selectedLayerId.value);
        selectedLayerId.value = null;
      };

      const handleKeyDown = (e) => {
        if (e.key !== 'Delete' && e.key !== 'Backspace') return;
        const el = document.activeElement;
        if (el) {
          const tag = el.tagName;
          if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable) return;
        }
        if (!selectedLayerId.value) return;
        e.preventDefault();
        deleteSelectedLayer();
      };

      // 2. Guardar / Cargar Proyecto
      const applyProject = (data) => {
        if (data.canvas) {
          Object.assign(canvasConfig, {
            width: data.canvas.width || canvasConfig.width,
            height: data.canvas.height || canvasConfig.height,
            bgColor: data.canvas.bgColor || canvasConfig.bgColor,
            showBorder: data.canvas.showBorder !== undefined
              ? data.canvas.showBorder
              : canvasConfig.showBorder
          });
          if (data.canvas.width === 1080 && data.canvas.height === 1920) {
            selectedFormat.value = 'vertical';
          } else if (data.canvas.width === 1920 && data.canvas.height === 1080) {
            selectedFormat.value = 'landscape';
          }
        }
        if (Array.isArray(data.layers)) {
          layers.value = data.layers.map((l) => ({
            ...l,
            fx: { ...defaultFx(), ...(l.fx || {}) }
          }));
        }
        selectedLayerId.value = null;
        updateScale();
      };

      const buildProjectData = () => ({
        version: 1,
        canvas: { ...canvasConfig },
        layers: layers.value.map((l) => JSON.parse(JSON.stringify(l)))
      });

      const setStatusMsg = (msg) => {
        statusMsg.value = msg;
        setTimeout(() => { statusMsg.value = ''; }, 3500);
      };

      const refreshJsonFiles = async () => {
        const res = await fetch('/api/thumbnail/json-files');
        const data = await res.json();
        jsonFiles.value = data.files || [];
      };

      const saveProjectToServer = async () => {
        try {
          const filename = (projectFilename.value.trim() || 'proyecto_' + Date.now()).replace(/\.json$/i, '') + '.json';
          const res = await fetch('/api/thumbnail/json/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              filename,
              canvas: { ...canvasConfig },
              layers: layers.value.map((l) => JSON.parse(JSON.stringify(l)))
            })
          });
          const result = await res.json();
          if (!res.ok) throw new Error(result.detail || 'Error al guardar');
          projectFilename.value = result.filename.replace(/\.json$/i, '');
          await refreshJsonFiles();
          setStatusMsg('Proyecto guardado como ' + result.filename);
        } catch (error) {
          console.error('Error al guardar el proyecto:', error);
          setStatusMsg('Error al guardar: ' + error.message);
        }
      };

      const downloadProjectJson = () => {
        const data = buildProjectData();
        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.download = (projectFilename.value.trim() || 'proyecto') + '.json';
        link.href = url;
        link.click();
        URL.revokeObjectURL(url);
        setStatusMsg('JSON descargado');
      };

      const loadProjectFile = async (filename) => {
        if (!filename) return;
        try {
          const res = await fetch('/api/thumbnail/json/' + encodeURIComponent(filename));
          const data = await res.json();
          if (!res.ok) throw new Error(data.detail || 'Error al cargar');
          applyProject(data);
          projectFilename.value = filename.replace(/\.json$/i, '');
          setStatusMsg('Proyecto cargado: ' + filename);
        } catch (error) {
          console.error('Error al cargar el proyecto:', error);
          setStatusMsg('Error al cargar: ' + error.message);
        }
      };

      const handleProjectUpload = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (evt) => {
          try {
            applyProject(JSON.parse(evt.target.result));
            projectFilename.value = file.name.replace(/\.json$/i, '');
            setStatusMsg('Proyecto importado: ' + file.name);
          } catch (error) {
            console.error('JSON inválido:', error);
            setStatusMsg('El archivo no es un JSON válido');
          }
        };
        reader.readAsText(file);
        e.target.value = '';
      };

      const svgWithFillColor = (svgText, color) => {
        return svgText
          .replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${color}"`)
          .replace(/(fill:)\s*#[0-9a-fA-F]{3,8}/g, `$1 ${color}`);
      };

      const loadBrushMasks = async () => {
        const results = await Promise.all(brushMasks.value.map(async (file) => {
          try {
            const res = await fetch('/static/imgs/brush/' + encodeURIComponent(file));
            const blob = await res.blob();
            if (file.toLowerCase().endsWith('.svg')) {
              const text = await blob.text();
              return [file, { svg: text }];
            }
            const dataUrl = await new Promise((resolve, reject) => {
              const reader = new FileReader();
              reader.onload = () => resolve(reader.result);
              reader.onerror = reject;
              reader.readAsDataURL(blob);
            });
            return [file, { dataUrl }];
          } catch (error) {
            console.error('Error cargando máscara brush ' + file + ':', error);
            return [file, null];
          }
        }));
        brushMaskUrls.value = Object.fromEntries(results.filter(([, v]) => v));
      };

      const brushMaskUrl = (layer) => {
        const entry = brushMaskUrls.value[layer.brushMask]
          || brushMaskUrls.value[brushMasks.value[0]]
          || null;
        if (entry && entry.svg) {
          const colored = svgWithFillColor(entry.svg, layer.bgColor);
          const bytes = new TextEncoder().encode(colored);
          let bin = '';
          bytes.forEach((b) => { bin += String.fromCharCode(b); });
          return 'url("data:image/svg+xml;base64,' + btoa(bin) + '")';
        }
        if (entry && entry.dataUrl) {
          return 'url("' + entry.dataUrl + '")';
        }
        return 'url("/static/imgs/brush/' + (layer.brushMask || '1.svg') + '")';
      };

      // 3. Integración con interact.js para arrastrar (Drag)
      onMounted(() => {
        interact('.draggable-layer').draggable({
          listeners: {
            move(event) {
              const id = Number(event.target.getAttribute('data-id'));
              const layer = layers.value.find(l => l.id === id);
              if (layer) {
                layer.x += event.dx / scale.value;
                layer.y += event.dy / scale.value;
              }
            }
          }
        });

        interact('.draggable-layer').resizable({
          edges: { left: true, right: true, top: true, bottom: true },
          modifiers: [
            interact.modifiers.restrictSize({ min: { width: 40, height: 20 } })
          ],
          inertia: false,
          listeners: {
            move(event) {
              const id = Number(event.target.getAttribute('data-id'));
              const layer = layers.value.find(l => l.id === id);
              if (!layer) return;
              const s = scale.value;
              layer.x += event.deltaRect.left / s;
              layer.y += event.deltaRect.top / s;
              layer.width = Math.max(40, layer.width + (event.deltaRect.left + event.deltaRect.right) / s);
              layer.height = Math.max(20, layer.height + (event.deltaRect.top + event.deltaRect.bottom) / s);
            }
          }
        });
        updateScale();
        window.addEventListener('resize', updateScale);
        window.addEventListener('keydown', handleKeyDown);
        refreshJsonFiles();

        fetch('/api/thumbnail/imgs')
          .then((r) => r.json())
          .then((data) => {
            logoFiles.value = data.logos;
            fragmentFiles.value = data.fragments;
            brushMasks.value = data.brush || [];
            loadBrushMasks();
          })
          .catch((error) => console.error('Error cargando imágenes:', error));

        fetch('/api/thumbnail/fonts')
          .then((r) => r.json())
          .then((data) => {
            const families = new Map();
            data.fonts.forEach((f) => {
              const isWoff2 = f.endsWith('.woff2');
              const current = families.get(fontFamilyName(f));
              if (!current || isWoff2) families.set(fontFamilyName(f), f);
            });
            fontFamilies.value = Array.from(families.keys()).sort();
            injectFontFaces(Array.from(families.values()));
          })
          .catch((error) => console.error('Error cargando fuentes:', error));
      });

      // 3. Exportar a PNG usando htmlToImage
      const ensureBrushMasksLoaded = async () => {
        const pending = brushMasks.value.filter((f) => !brushMaskUrls.value[f]);
        if (pending.length) await loadBrushMasks();
      };

      const exportImage = async () => {
        try {
          selectedLayerId.value = null;
          isExporting.value = true;
          await ensureBrushMasksLoaded();
          await nextTick();
          if (document.fonts) await document.fonts.ready;

          const dataUrl = await htmlToImage.toPng(canvasRef.value, {
            quality: 0.95,
            pixelRatio: 1,
            width: canvasConfig.width,
            height: canvasConfig.height,
            style: {
              transform: 'none',
              transformOrigin: 'top left'
            }
          });

          // Descarga de prueba
          const link = document.createElement('a');
          link.download = 'portada.png';
          link.href = dataUrl;
          link.click();

        } catch (error) {
          console.error('Error al exportar la imagen:', error);
        } finally {
          isExporting.value = false;
        }
      };

      return {
        canvasRef,
        stageRef,
        canvasConfig,
        canvasFormats,
        selectedFormat,
        handleFormatChange,
        scale,
        layers,
        selectedLayerId,
        selectedLayer,
        isExporting,
        logoFiles,
        fragmentFiles,
        selectedLogo,
        selectedFragment,
        fontFamilies,
        jsonFiles,
        selectedJsonFile,
        projectFilename,
        statusMsg,
        brushMasks,
        brushSelection,
        brushMaskUrl,
        shadeColor,
        layerBoxShadow,
        layerTextShadow,
        layerBorder,
        layerFilter,
        addBrushTextLayer,
        saveProjectToServer,
        downloadProjectJson,
        loadProjectFile,
        handleProjectUpload,
        addTextLayer,
        addNormalTextLayer,
        handleImageUpload,
        addProjectImage,
        selectLayer,
        deleteSelectedLayer,
        exportImage
      };
    }
  }).mount('#app');
</script>

Gestión de proyectos mediante JSON

Para evitar la pérdida de composiciones o permitir reutilizar diseños previos, el sistema permite guardar y cargar el estado del lienzo en archivos con formato JSON.

Cada elemento añadido (textos, logos, pinceladas, contenedores) se registra dentro de un arreglo de capas (layers) que almacena sus dimensiones, posición, color, tipografía y contenido. Al exportar el proyecto, se genera un archivo .json que puede ser importado posteriormente para restaurar exactamente la misma distribución gráfica.

Conclusión

La combinación de HTML, CSS, JavaScript y Vue.js resulta muy superior a las soluciones estáticas en backend para la maquetación de recursos gráficos. Proporciona una interfaz intuitiva, retroalimentación visual en tiempo real y la flexibilidad necesaria para adaptar las imágenes a diferentes formatos (16:9 para videos tradicionales o 9:16 para formatos verticales/shorts).

Aprende a generar imágenes con HTML CSS y JavaScript con Vue.js, componer SVG, generar efectos pincelados, textos descagados, cargar fragmentos de imágenes y exportar en PNG.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.