<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Posts on Inobtenio</title>
    <link>https://inobtenio.com/posts/</link>
    <description>Recent content in Posts on Inobtenio</description>
    <image>
      <title>Inobtenio</title>
      <url>https://cdn.inobtenio.com/Ub-simple-dark.png</url>
      <link>https://cdn.inobtenio.com/Ub-simple-dark.png</link>
    </image>
    <generator>Hugo -- 0.143.0</generator>
    <language>es-PE</language>
    <managingEditor>knmartinm@gmail.com (Kevin Martin)</managingEditor>
    <webMaster>knmartinm@gmail.com (Kevin Martin)</webMaster>
    <lastBuildDate>Sat, 14 Mar 2026 11:11:11 -0500</lastBuildDate>
    <atom:link href="https://inobtenio.com/posts/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Actualizando mi Home Assistant Yellow con un Raspberry Pi Compute Module 5 el día de Pi</title>
      <link>https://inobtenio.com/posts/home-assistant-yellow-cm5-upgrade/</link>
      <pubDate>Sat, 14 Mar 2026 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/home-assistant-yellow-cm5-upgrade/</guid>
      <description>Conseguí un CM5 a un gran precio, así que hice con él lo que cualquier persona haría, actualicé mi Home Assistant Yellow por puro gusto.</description><enclosure url="https://inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5.webp" length="0" type="image/webp" />
      <content:encoded><![CDATA[<p>No sé si lo he comentado en algún otro post, pero en este hogar somos militantes de la religión de <a href="https://www.home-assistant.io/" target="_blank">Home Assistant</a>, alguna vez el repositorio de GitHub con <a href="https://octoverse.github.com/2022/state-of-open-source#:~:text=Top%20open%20source%20projects%20by,growth%20over%20the%20past%20year." target="_blank">más crecimiento</a>. Nuestra historia comenzó en 2017 cuando empecé mis travesuras con una poco potente pero terriblemente versátil <a href="https://www.raspberrypi.com/products/raspberry-pi-3-model-b-plus/" target="_blank">Raspberry Pi 3B+</a> (por cierto, la compañía de la frambuesa estuvo <a href="https://www.raspberrypi.com/news/happy-birthday-to-us-3/" target="_blank">recientemente de aniversario</a>) que me permitía prender y apagar algún foco inteligente que tenía por ahí.<br>
Era yo un niño con juguete nuevo jugando todo el día con un triste foco. Qué felicidad.</p>
<p>Varios años después y con bastante más experiencia pude conseguir un <a href="https://www.home-assistant.io/yellow/" target="_blank">Home Assistant Yellow</a>, basado también en hardware de Raspberry Pi pero que prometía ofrecer la mejor experiencia por un precio reducido, a la vez que ayudaba directamente a mantener el proyecto libre para todos, ya que era diseñado y distribuido por la misma <a href="https://www.nabucasa.com/" target="_blank">Nabu Casa</a>, empresa creada a partir de Home Assistant, y lo he estado usando desde entonces sin mayores problemas. Es estable, eficiente y, por lo general, se puede decir que simplemente funciona, así que ha sido una buena inversión.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Un Yellow bonito y ensambladito" height="470" title="Un Yellow bonito y ensambladito" width="720"srcset="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_assembled_hu_2d1d458568c6d9c5.webp 480w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_assembled_hu_5faf33a41925854.webp 720w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_assembled_hu_d720442cc9eb1b83.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_assembled_hu_4a52a6b821ada8ba.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_assembled_hu_4a52a6b821ada8ba.webp"><figcaption>Un Yellow bonito y ensambladito</figcaption></figure><p>El hardware es correcto pero nada demasiado potente, el corazón del sistema es un <a href="https://www.raspberrypi.com/products/compute-module-4/" target="_blank">Compute Module 4</a>, que es básicamente una Raspberry Pi 4B+ pero sin puertos y con memoria eMMC incluida (dato no menor), que es como una tarjeta MicroSD que viene soldada de fábrica en la propia placa, sólo que un poco más confiable y, a veces, rápida. Es, además, más que adecuado para el uso que le doy en el día a día a mi instancia de HA: un par de decenas de sensores por aquí, unos cuantos focos por allá, algunas automatizaciones y poco más, es raro ver que el uso de mi CM4 supere el 10% de su capacidad en cuanto a CPU.<br>
Como dije, ha estado trabajando bastante bien durante los últimos dos años, así que poca queja.</p>
<p>Sin embargo, hace unos días tuve la oportunidad de conseguir un <a href="https://www.raspberrypi.com/products/compute-module-5/?variant=cm5-104032" target="_blank">Compute Module 5</a> al precio sugerido por la propia Raspberry Pi y no pude resistir las ganas de comprarlo. Lo único malo es que comprar un módulo con el chip eMMC incluido se me iba un poco de presupuesto, así que realicé una búsqueda rápida en Google para ver si aún así funcionaría bien en mi Yellow y todo parecía ser buenas noticias, así que procedí.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Compute Module 4 y Compute Module 5" height="355" title="Compute Module 4 y Compute Module 5 (imagen de [CNX Software](https://www.cnx-software.com/))" width="720"srcset="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5_hu_4133deb1acd6cf8c.webp 480w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5_hu_9510598ff21cce58.webp 720w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5_hu_d1d4eec70e17edc6.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5_hu_dd1b04e52bfa8075.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm4_vs_cm5_hu_dd1b04e52bfa8075.webp"><figcaption>Compute Module 4 y Compute Module 5 (imagen de <a href="https://www.cnx-software.com/" target="_blank">CNX Software</a>)</figcaption></figure><p>Los dos módulos tienen el mismo <em>footprint</em> físico, así que, <strong>en teoría</strong>, este debería ser un reemplazo de quitar y poner.
En teoría.</p>
<p>Hice un <em>backup</em> de configuración y otro de sistema por si acaso y los transferí a una computadora externa. Todo estaba listo para el <em>upgrade</em>.</p>
<p>Primer obstáculo: El CM5 no me vino con <a href="https://en.wikipedia.org/wiki/Thermally_conductive_pad" target="_blank"><em>thermal pads</em></a>, así que al retirar el CM4 tuve que reemplazar el <em>pad</em> del procesador por un poco de pasta térmica que compré hace años para cuando tenga que realizar ensamblajes más tradicionales de computadoras de escritorio.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Juro que en los Intel y AMD aplico bastante mejor la pasta" height="426" title="Juro que en los Intel y AMD aplico bastante mejor la pasta" width="720"srcset="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm5_thermal_paste_hu_b7ca4b330b5816d3.webp 480w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm5_thermal_paste_hu_3ae6a7091601f8f8.webp 720w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm5_thermal_paste_hu_cbdef01faf6a449d.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm5_thermal_paste_hu_b056de823f5ada11.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/cm5_thermal_paste_hu_b056de823f5ada11.webp"><figcaption>Juro que en los Intel y AMD aplico bastante mejor la pasta</figcaption></figure><p>Una vez realizado el cambio de hardware, simplemente volví a conectar el Yellow re ensamblado a la red y esperé verlo revivir después de un par de minutos.</p>
<p>5 minutos después sabía que había cometido alguna torpeza.</p>
<p>Resulta, según algunos foros y <a href="https://support.nabucasa.com/hc/en-us/articles/25607960026397-Migrating-from-Home-Assistant-Yellow-Kit-with-CM4-to-a-CM5" target="_blank">la guía oficial de actualización de CM4 a CM5 de Home Assistant Yellow</a> que el CM5 tiene una secuencia de inicio un poco distinta a la del modelo anterior; además, y como mencioné líneas arriba, es de vital importancia que el CM5 no posea un chip eMMC. No estoy 100% seguro de no haber usado nunca ese chip en el caso del CM4 cuando armé por primera vez mi Yellow, pero aún habiéndolo querido evitar es posible que el módulo esté configurado para sí o sí usar esa memoria interna para almacenar algunas cosas, memoria que no estaría presente en el nuevo módulo.</p>
<p>Felizmente tenía la guía y los <em>backups</em>. La idea era usar el disco NVMe como única fuente de almacenamiento, para eso había que borrar absolutamente todo lo que había ahí con anterioridad, instalar Home Assistant OS nuevamente y luego hacer una restauración a partir de las copias de respaldo que había obtenido minutos atrás.</p>
<p>Obviamente, es una simplificación ligeramente obscena del proceso que tuve que llevar a cabo, pero los detalles se explican bastante mejor en la guía enlazada un par de párrafos arriba.</p>
<p>No fue tan fácil, tuve que buscar entre mis cachivaches una fuente de alimentación de 12V para poder encender el Yellow e instalar lo necesario mientras lo tenía conectado a mi computadora de escritorio, en donde tenía los <em>backups</em> y también un par de herramientas (mencionadas en la guía) vitales para el proceso. Lamentablemente, no tengo acceso a un cable Ethernet con capacidad <a href="https://en.wikipedia.org/wiki/Power_over_Ethernet" target="_blank">PoE</a> en mi escritorio.</p>
<p>Ahora que lo pienso, hubiera sido más fácil usar una laptop y llevarla hacia donde normalmente reside mi Yellow, así no hubiera tenido que buscar una fuente alternativa de alimentación.<br>
Menos mal este artículo no se trata de mi capacidad intelectual en momentos oportunos.</p>
<p>Home Assistant tiene un asistente de restauración de copias de seguridad bastante simple pero amigable, así que no fue nada difícil restablecer el sistema tal cual como lo dejé antes de la actualización.<br>
Hubo un par de cositas por corregir, como el coordinador Bluetooth que, al parecer, había cambiado de dirección MAC y algo bastante similar con el coordinador <a href="" target="_blank">Zigbee</a>, así que ni mi sensor de temperatura ni mis <a href="" target="_blank">Philips Hue</a> funcionaron apenas terminado el proceso, pero la solución fue bastante simple: en el caso del Bluetooth, el &ldquo;nuevo&rdquo; coordinador estaba listo para ser adoptado, sólo lo acepté y eliminé el anterior, nada más que hacer; en el caso de la antena Zigbee, más de lo mismo, aceptar el nuevo ID y dejar que los <em>handshakes</em> necesarios se realicen por sí mismos.</p>
<h2 id="ya-pero-eres-o-no-eres-mejor-que-el-cm4">Ya, pero ¿eres o no eres&hellip; mejor que el CM4?</h2>
<p>Sí, sí, claro que lo es. También es claro que no era necesario hacer el cambio, pero como entusiasta de este tipo de trivialidades, juguete nuevo es juguete nuevo.</p>
<p>Hice unas pequeñas pruebas tanto en el CM4 como en el CM5 para ver qué tan más rápido era el nuevo modelo:</p>
<p><strong>CM4</strong>:</p>
<table class="width35perc">
  <thead>
      <tr>
          <th>proceso</th>
          <th>tiempo</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Creación de copia de seguridad</td>
          <td>2:34</td>
      </tr>
      <tr>
          <td>Reinicio de HA</td>
          <td>2:05</td>
      </tr>
      <tr>
          <td>Reinicio del sistema</td>
          <td>3:29</td>
      </tr>
  </tbody>
</table>
<p><strong>CM5</strong>:</p>
<table class="width35perc">
  <thead>
      <tr>
          <th>proceso</th>
          <th>tiempo</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Creación de copia de seguridad</td>
          <td>0:48</td>
      </tr>
      <tr>
          <td>Reinicio de HA</td>
          <td>1:04</td>
      </tr>
      <tr>
          <td>Reinicio del sistema</td>
          <td>2:19</td>
      </tr>
  </tbody>
</table>
<p>No son las pruebas más adecuadas científicamente hablando pero ver mejoras de hasta casi el 69% (nice) en tareas del día a día es bastante significativo.</p>
<p>Obviamente, todo ese poder no es gratuito y la capacidad extra de procesamiento se tiene que pagar de alguna manera.<br>
No me preocupé de tomar muestras del comportamiento térmico de los dos distintos módulos pero asumiré que es relativamente similar, aunque es cierto que el Yellow se siente un poquito más <a href="https://www.myinstants.com/en/instant/toasty-mortal-kombat-52053/" target="_blank"><em>toasty</em></a> al contacto, aunque nada preocupante. La temperatura promedio del CM5 con el uso que le doy a diario empleando el disipador de fábrica es de aproximadamente 69 °C, no extremadamente saludable pero nada demasiado preocupante tampoco. Quizás si consigo un <em>thermal pad</em> adecuado el disipador pueda hacer un mejor contacto y reducir un poco esa temperatura, pero por ahora es un número decente.</p>
<p>De lo que sí me preocupé fue del consumo energético. Con la ayuda de mi <a href="https://store.ui.com/us/en/products/usw-24-poe" target="_blank">USW-24-PoE</a> pude verificar que, efectivamente, la quinta versión del módulo consume más energía que la cuarta, nada sorpresivo, pero nada como tener los datos en la mano:</p>
<figure class="width85perc">
  <img loading="lazy" alt="Consumo energético con el CM4" height="235" title="Consumo energético con el CM4" width="720"srcset="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm4_hu_2e069272fc76d111.webp 480w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm4_hu_e18dff6a08cc5068.webp 720w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm4_hu_4dbda6578ac86dff.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm4_hu_6fcff83c97b81340.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm4_hu_6fcff83c97b81340.webp"><figcaption>Consumo energético con el CM4</figcaption></figure><figure class="width85perc">
  <img loading="lazy" alt="Consumo energético con el CM5" height="233" title="Consumo energético con el CM5" width="720"srcset="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm5_hu_18e0acf1c5b853d2.webp 480w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm5_hu_af92249d9e22ed7e.webp 720w ,https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm5_hu_7fd21544b086627b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm5_hu_53f54e64882feecf.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/home-assistant-yellow-cm5-upgrade/yellow_watts_cm5_hu_53f54e64882feecf.webp"><figcaption>Consumo energético con el CM5</figcaption></figure><p>Como se ve en los gráficos, la diferencia promedio en consumo es de 1W después de la actualización. ¿es eso mucho o poco? Bueno, para tener una idea un poco más clara, es más o menos lo que un teléfono inteligente moderno usa mientras se navega por la web, o, de nuevo más o menos, lo que consume la linterna de dicho teléfono al máximo.</p>
<p>Quizás en términos monetarios se entienda un poco mejor: el mayor consumo del CM5 frente al CM4 representaría <strong>al año</strong>, teniendo en cuenta el precio actual de la electricidad en mi ciudad, un gasto de aproximadamente <strong>un dólar y medio más</strong>. O sea, casi nada.</p>
<p>Es una pena que Home Assistant haya <a href="https://www.home-assistant.io/blog/2025/10/15/yellow-end-of-life/" target="_blank">descontinuado</a> el, en mi opinión, mejor producto que pusieron en el mercado en todos estos años, aunque confío firmemente en su <a href="https://www.home-assistant.io/blog/2025/10/15/yellow-end-of-life/#software-support-continues" target="_blank">compromiso de seguir ofreciendo soporte para él</a>. Si bien ahora existe <a href="https://www.home-assistant.io/green/" target="_blank">Green</a>, que parece bastante bueno también, creo que la capacidad, flexibilidad y características de Yellow lo hacen claramente superior aunque un poco más costoso.</p>
<p>Si tienen la oportunidad de conseguir un Yellow, incluso de segunda mano, no podría yo recomendar más su compra, más aún si lo van a emparejar con una pequeña bestia como el CM5. Esa cajita tendrá todo lo que necesitan de un sistema de domótica por los siguientes 5 años como mínimo.<br>
Si un módulo de 75 dólares es muy caro, incluso con un CM4 de unos 50 dólares se puede tener una experiencia más que decente.</p>
<p>Ah, y <a href="https://www.un.org/en/academic-impact/happy-international-pi-day" target="_blank"><em>Happy International Pi Day</em></a>. &#x1f600;</p>
]]></content:encoded>
    </item>
    <item>
      <title>Quería un poco de luz en la habitación, así que diseñé un circuito impreso</title>
      <link>https://inobtenio.com/posts/wled-lamp-pcb/</link>
      <pubDate>Sat, 01 Nov 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/wled-lamp-pcb/</guid>
      <description>Dado que mis lamparitas caseras no estaban funcionando del todo bien ideé la solución más exageradamente elaborada que se me ocurrió para tener un poco de paz y también de qué escribir.</description><enclosure url="https://inobtenio.com/img/posts/wled-lamp-pcb/WLED_corner.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>Como contaba en <a href="/posts/dark-side-3d-printing/" target="_blank">el post anterior</a>, estaba terminando de colocar unas lamparitas que harían de mi <em>setup</em> de cine en casa un 3% más inmersivo, pero me di cuenta de que cada cierto tiempo y de forma aparentemente aleatoria tendían a parpadear, tal como se puede apreciar aquí abajo:</p>
<figure class="video width40perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/wled-lamp-pcb/lamp_working.mp4">
</video>
</figure>

<p>Mi primera idea fue que había cometido algún error al momento de configurar <a href="https://kno.wled.ge/" target="_blank">WLED</a>, aunque me pareció un poco raro porque no era la primera instalación del estilo que hacía, aún así me metí a los foros y encontré que el <em>flickering</em> en WLED (y con varios otros <em>software</em> de controlador para LEDs) es bastante común si no se tiene en cuenta ciertas cosas al momento de hacer las conexiones eléctricas entre el controlador y las luces.<br>
Cosas como el voltaje al que funcionan los LEDs, la longitud de los cables, si los mismos están acompañados o no por otros cables, qué tipo de señal llevan los cables que acompañan y varias otras cosas pueden afectar el funcionamiento de una instalación.</p>
<p>Por supuesto, yo no había tenido en cuenta ninguna de estas cosas, así que sí era un error de configuración de mi lado, y me puse a mejorar todos los aspectos de mi proyecto que podrían contribuir a un funcionamiento más estable.</p>
<p>El diagrama eléctrico inicial era más o menos así:</p>
<figure class="width60perc">
  <img loading="lazy" alt="Diagrama básico WLED" height="422" title="Básico de básicos" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche1_hu_48d93412f2a263bd.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche1_hu_50724ca00002c591.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche1_hu_54cb9a83ea267bf7.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche1_hu_4ad632b8fb562d0d.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche1_hu_4ad632b8fb562d0d.webp"><figcaption>Básico de básicos</figcaption></figure><p>Y ya, sólo eso. Nada de resistencias, condensadores o diodos. Ninguna forma de compensar los cerca de 10 metros de cable que habría entre el controlador (un <a href="https://www.nodemcu.com/index_en.html" target="_blank">NodeMCU ESP8266</a> en este caso), eso sin tener en cuenta que los cables durante una parte no despreciable de su recorrido están acompañados muy de cerca de varios otros que llevan señales como Ethernet y HDMI y durante otra parte, menos despreciable aún, lo están de cables de flujo eléctrico alterno a 220V y 60Hz, lo cual casi definitivamente estaba causando algún tipo de <a href="https://en.wikipedia.org/wiki/Crosstalk" target="_blank"><em>crosstalk</em></a>, o sea que las señales se contaminaban unas a otras.<br>
Me parece que la señal que envía el pequeño microcontrolador iba a pasar un mal momento realmente.</p>
<p>¿por qué no cambiar el cableado? Bueno, no es mala idea, pero la configuración eléctrica actual de la habitación en donde desplegué estas lámparas hace complicado llevarla a cabo. Además, me interesa ocultar los cables tanto como se pueda y eso significa en muchos casos utilizar tuberías dentro de las paredes que, en un principio, sólo tenían como tarea servir como canal para cables de alta tensión (bueno, alta con respecto a los voltajes que requiere el proyecto en cuestión, ustedes entienden).</p>
<p>Además, aún sin tanto cable al lado, pretender que una señal de varios megahercios y 3.3V (el voltaje al que funcionan las terminales del microcontrolador) mantenga su integridad a través de un cable de 20<a href="https://en.wikipedia.org/wiki/American_wire_gauge" target="_blank">AWG</a> y sin protección a lo largo de más de 10 metros es, como mínimo, iluso y como máximo mejor no lo digo.</p>
<h2 id="el-tercermundismo-contraataca">El tercermundismo contraataca</h2>
<p>La solución más a la mano parecía ser conseguir algún controlador ya construido para sólo conectarle los cables necesarios y disfrutar. De hecho, hay varios proyectos que apuntan precisamente a eso, algunas personas han dedicado su tiempo a diseñar circuitos <em>plug and play</em> para WLED para los que no tenemos demasiada idea de cómo conectar bien un par de LEDs. Seguramente el más notable sea <a href="https://quinled.info/" target="_blank">QuinLED</a>, que tiene varios productos interesantes pero, como de costumbre, una de dos opciones: el envío hacia este rincón olvidado del mundo es inexistente o cuesta el triple de lo que cuesta el producto.</p>
<p>Además, eso no es divertido.</p>
<p>Una vez más, tenía que hacerlo yo mismo, bien <em>punk</em>.</p>
<h2 id="fine-ill-do-it-myself">Fine, I&rsquo;ll do it myself</h2>
<p>Junté algunas resistencias, condensadores, <a href="https://en.wikipedia.org/wiki/Level_shifter" target="_blank">elevadores lógicos de voltaje</a> y otras cositas que tenía por ahí en la mesa como cualquier persona y volví a armar el circuito.</p>
<figure class="width60perc">
  <img loading="lazy" alt="Diagrama no tan básico WLED" height="402" title="Aaaah, otra cosita" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche2_hu_636fe44ea0635e92.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche2_hu_f176a08853f74aab.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche2_hu_46f2f3f8d9f448f7.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche2_hu_6ea6aa3b9f0aa39f.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/sche2_hu_6ea6aa3b9f0aa39f.webp"><figcaption>Aaaah, otra cosita</figcaption></figure><p>En el mundo real se veía más o menos así:</p>
<figure class="width60perc">
  <img loading="lazy" alt="Prototipo" height="391" title="Si lo miras con atención por suficiente tiempo empieza a tener sentido" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/proto_hu_4e531a34aca9a535.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/proto_hu_fd290d4f91bc9c0d.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/proto_hu_69ef6e568199713c.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/proto_hu_8213451612bcf90.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/proto_hu_8213451612bcf90.webp"><figcaption>Si lo miras con atención por suficiente tiempo empieza a tener sentido</figcaption></figure><p>Siempre me causó gran curiosidad qué causaba esos destellos y cómo se verían reflejados en una gráfica de la señal. Era momento de sacar las armas pesadas.</p>
<p>Con ayuda de un <a href="https://rigolshop.eu/dho804.html" target="_blank">RIGOL DHO804</a> me puse a husmear en las señales que enviaba el microcontrolador, o las que recibían las tiras LED 10 metros más allá, mejor dicho.</p>
<figure class="width60perc">
  <img loading="lazy" alt="Circuito básico" height="422" title="Circuito básico" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/no_booster_zoom_out_1_hu_896326c403eaf71c.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/no_booster_zoom_out_1_hu_69b1d39c4db57688.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/no_booster_zoom_out_1_hu_b8e177b437d9e696.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/no_booster_zoom_out_1_hu_256605189a7f1c6f.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/no_booster_zoom_out_1_hu_256605189a7f1c6f.webp"><figcaption>Circuito básico</figcaption></figure><figure class="width60perc">
  <img loading="lazy" alt="Circuito no tan básico" height="422" title="Circuito no tan básico" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/booster_zoom_out_1_hu_271f6e8f92ff1b5d.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/booster_zoom_out_1_hu_213a49a317c7cebd.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/booster_zoom_out_1_hu_9b836a94b10b4157.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/booster_zoom_out_1_hu_eda96fd098884c4a.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/booster_zoom_out_1_hu_eda96fd098884c4a.webp"><figcaption>Circuito no tan básico</figcaption></figure><p>Para ser justos, ninguna de las dos es una señal perfectamente cuadrada, tampoco están particularmente cerca de serlo, aunque creo que sí es apreciable la reducción de <a href="https://en.wikipedia.org/wiki/Ringing_%28signal%29" target="_blank"><em>ringing</em></a> en el circuito no tan básico, pero lo más importante, por lo menos a simple vista, es que en el segundo circuito la señal alcanza picos de voltage más altos, producto de usar un elevador lógico de voltage como el <a href="https://www.ti.com/lit/ds/symlink/sn74ahct125.pdf" target="_blank">SN74AHCT125N</a> de <a href="https://www.ti.com/" target="_blank">Texas Instruments</a>, lo cual permite que viaje por más distancia manteniendo un poco mejor su integridad.</p>
<p>Las mediciones en los circuitos mismos (es decir, sin tomar en cuenta la longitud del cable que lleva la señal) son bastante más parecidas en cuanto <em>ringing</em>. Y es de esperar, la verdadera diferencia debería ser aparente una vez la señal haya recorrido que generaba el problema en primer lugar.</p>
<h2 id="but-first-a-word-from-todays-sponsor-jlcpcb">&ldquo;<em>But first, a word from today&rsquo;s sponsor, JLCPCB</em>&rdquo;</h2>
<p>Comprobado el funcionamiento del nuevo circuito, era hora de mejorar la implementación. No podía dejar esa maraña de cables ahí. Incluso si pudiera ponerlo dentro de una caja para un mejor resultado estético, internamente no sería muy confiable, los <a href="https://www.reddit.com/r/electronics/comments/ioc6sf/i_finally_foundout_why_dupont_connectors_are/" target="_blank">cables DuPont</a> y los <a href="https://en.wikipedia.org/wiki/Breadboard" target="_blank">breadboards</a> son excelentes para la fase de prototipado pero no para una solución a largo plazo, aunque muchas veces lo temporal se vuelve <a href="https://www.reddit.com/r/ProgrammerHumor/comments/141rtkr/temporary_fix/#lightbox" target="_blank">permanente</a>, lamentablemente.</p>
<p>Pensé en usar una placa universal y soldar ahí los componentes necesarios pero parecía una idea un tanto incompleta. Incompleta porque es un paso intermedio entre un <em>breadboard</em> y un circuito impreso, como los que se encuentran normalmente dentro de los aparatos electrónicos estos días. No me sentía muy cómodo sabiendo que se vería, en mi opinión, tan poco profesional. No es que yo suela solucionar mis problemas electrónicos de la forma más profesional posible pero también es cierto que si ya iba a ponerme a soldar componentes en una placa, no veía un gran motivo por el cual no poner todas las fichas en el proyecto y diseñar un circuito integrado yo mismo.<br>
Bueno, ese fue el razonamiento inicial, resulta que el gran motivo era la considerable inversión de tiempo necesaria para poder diseñar un <a href="https://en.wikipedia.org/wiki/Printed_circuit_board" target="_blank"><em>PCB</em></a>, <em>silly me</em>.</p>
<p>Hace un buen tiempo que quería aprender a hacer este tipo de cositas, siempre creí que era muy complicado pero esta era la oportunidad ideal, era un circuito bastante simple, yo tenía algo de tiempo para disponer y, bueno, tenía ganas.<br>
Un par de posts de Reddit después llegué a <a href="https://easyeda.com/" target="_blank">EasyEDA</a>, me gustó que era lo suficientemente simple de usar para un novato como yo y, también, su integración con <a href="https://jlcpcb.com/" target="_blank">JLCPCB</a>. Más sobre esto último un poco más abajo.</p>
<p>Luego de un poco (un montón, en realidad) de prueba y error, llegué a esto:</p>
<figure class="width60perc">
  <img loading="lazy" alt="Circuito impreso final (diseño, parte delantera)" height="474" title="Circuito impreso final (parte delantera)" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_front_hu_20810dcc5996f44.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_front_hu_bcfed12b6b01173e.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_front_hu_68e6f6c3e375e717.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_front_hu_28c5db9c37a3a2c0.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_front_hu_28c5db9c37a3a2c0.webp"><figcaption>Circuito impreso final (parte delantera)</figcaption></figure><figure class="width60perc">
  <img loading="lazy" alt="Circuito impreso final (diseño, parte delantera)" height="476" title="Circuito impreso final (parte trasera)" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_back_hu_3a789ff3631b8fa1.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_back_hu_7f6a5d0a013cd3a8.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_back_hu_6a9e90f81a375435.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_back_hu_fdd9d45986dd87bb.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/final_pcb_back_hu_fdd9d45986dd87bb.webp"><figcaption>Circuito impreso final (parte trasera)</figcaption></figure>



<figure>
  <div class="model-container" id="model-container-pcb"></div>
  <figcaption>Circuito impreso final y componentes (modelo 3D)</figcaption>
</figure>

<script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.179.1/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.179.1/examples/jsm/"
    }
  }
</script>

<script type="module">

  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls';
  import { DragControls } from 'three/addons/controls/DragControls';
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
  import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
  import { MTLLoader  } from 'three/addons/loaders/MTLLoader.js';
  import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

  const modelId = "pcb";
  const modelSrc = "\/special\/posts\/wled-lamp-pcb\/WLED_1.glb";
  const elem = document.getElementById(`model-container-${modelId}`);
  const scale =  3 ;
  const drag = "";

  elem.style.filter = 'saturate(1.25)';

  var conf = {
      movement: false,
      rotation: false,
      reset: function() { resetWorld() }
  }

  var gui = new GUI({container: elem});
  
  gui.add(conf, 'rotation').name('Rotate');
  gui.add(conf, 'reset').name('Reset');
  gui.close();

  const loader = new GLTFLoader();

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.setPixelRatio( window.devicePixelRatio );

  THREE.ColorManagement.legacyMode = false;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf1f1f1);

  scene.add(new THREE.AxesHelper(5));

  const ambientLight = new THREE.AmbientLight( 0xffffff, 1.8 );
  scene.add(ambientLight);

  const lightGroup = new THREE.Group();
  const positions = [
    [ 0,  10,  0],  

    [ 10,  0,  0],  
    [-10,  0,  0],  
    [ 0,   0, 10],  
    [ 0,   0,-10],  
  ]

  positions.forEach(([x, y, z]) => {
    const point = new THREE.DirectionalLight(0xffffff,  4 );
    point.position.set(x, y, z);
    lightGroup.add(point);
  });

  scene.add(lightGroup);

  const camera = new THREE.PerspectiveCamera(20, 16/9, 1, 1000);
  camera.position.set(40, 35, 50);
  camera.lookAt(new THREE.Vector3());
  scene.add(camera);

  const controls = new OrbitControls(camera, renderer.domElement);

  controls.enableDamping = true;
  controls.rotateSpeed = 0.25;
  controls.dampingFactor = 0.2;
  controls.enableZoom = true;
  controls.autoRotate = false;

  var objects = []

  let t = 0;
  let count = 0;
  let objectIndex = 0;

  let vectorMultiplier = 0.002;
  let animationDistanceFactor = 1;
  let animationDistance = animationDistanceFactor/vectorMultiplier;

  var move = function(count, mesh, vector, distance) {
    if (vector && distance) {
      var fff = vectorMultiplier*distance;
      if (count > animationDistance) fff = -fff;
      mesh.position.add(new THREE.Vector3(...vector).multiplyScalar(fff))
    }
  }

  var colors, opacities, vectors, distances;

  var load = function(filename) {
    loader.load(filename, function(object) {

			const model = object.scene;
			const obj = model.children[0];

			model.traverse((child) => {
				if (child.isMesh && child.material) {
					
					
					objects.push(child);

					child.userData.initialPosition = child.position.clone();

					child.material = child.material.clone();

					objectIndex++;
				}
			});

			model.scale.setScalar(scale);
      model.position.y = -2;
      scene.add(model);

      


      function animate() {
        requestAnimationFrame(animate);

        if (conf.movement && vectors && distances) {
          if (conf.movement && count < 2*animationDistance) {
            count++;
            objects.forEach(function(obj, index) {
              move(count, obj, vectors[index], distances[index]);
            })
          }
        }

        if (count >= 2*animationDistance) {
          count = 0;
          conf.movement = false;
        }

        if (conf.rotation) {
          controls.autoRotate = true;
          controls.autoRotateSpeed = 0.25;
        } else {
          controls.autoRotate = false;
        }

        controls.update();
        renderer.render(scene, camera);
      }

      elem.appendChild(renderer.domElement);
      animate();
    }, undefined, function(error) {
        console.error(error);
    });
  }

  var resetWorld = function() {
    count = 0;
    controls.reset();
    objects.map(function(o) {
      o.position.copy(o.userData.initialPosition);
      t = 0;
      conf.movement = false;
    })
  }

	load(modelSrc);

  var vectors = [[1,0,0], [0,1,0], [0,-1,0], [-1,0,0], [0,1,0], [0,-1,0]];
  var distances = [5, 4, 4, 3, 2.5, 2.5];

  if (drag) {
    const controls2 = new DragControls(objects, camera, renderer.domElement);

    controls2.addEventListener( 'dragstart', function ( event ) {

      conf.movement = false;
      event.object.material.emissive.set( 0xaaaaaa );

    } );

    controls2.addEventListener( 'dragend', function ( event ) {

      event.object.material.emissive.set( 0x000000 );

    } );
  }

</script>

<p>Como decía píxeles más arriba, algo que me gustó mucho de EasyEDA fue su integración con JLCPCB. Yo sólo conocía a esta compañía debido a la constante publicidad que hacen en canales de YouTube de tecnología y electrónica que suelo seguir (junto con <a href="https://www.pcbway.com/" target="_blank">PCBWay</a>, las dos más conocidas en este rubro gracias a sus prominentes campañas de mercadotecnia), pero no tenía idea de que era un distribuidor de componentes electrónicos también. Bueno, no ellos, si no su empresa hermana <a href="https://www.lcsc.com/" target="_blank">LCSC</a>.</p>
<p>La ventaja es que, al momento de diseñar tanto el esquema como el circuito en sí, se puede buscar componentes en su catálogo y usarlos sin tener que definir características manualmente, ahorrando así un montón de tiempo. Sirve mucho, además, para obtener los planos 2D y los modelos 3D de las partes involucradas, es un verdadero golazo.</p>
<p>Hubiera sido muy difícil para mí generar el modelo en 3D de todo el circuito terminado. Me siento cómodo haciendo algunas cosas en 3D pero no a ese nivel ni con ese nivel de precisión.</p>
<p>JLCPCB, claramente, no sólo vende piecitas electrónicas, también se dedica a lo que me interesaba en ese momento, la fabricación de circuitos impresos. Gracias, nuevamente, a la integración con EasyEDA (es su programa de asistencia de diseño de PCBs después de todo), una vez terminado mi diseño pude simplemente subirlo a sus servidores y obtener una cotización en un par de minutos. Los precios para la fabricación de un circuito básico de dos capas como este son bastante asequibles, el problema, como de costumbre suele ser el envío.</p>
<p>Para un mejor detalle, este es el desglose del total que tuve que pagar para tener mis ansiadas placas finalmente en mis manos:</p>
<figure class="width85perc no-responsive no-conversion">
  <img loading="lazy" alt="Orden detallada de JLCPCB" height="266" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/jlcpcb_order.png"></figure><p>Un poco más de 6 dólares por 10 placas, aunque pequeñas y básicas, me parece un gran precio. Como dije, el problema es el envío. No quería estar esperando 3 meses para finalizar el proyecto ni tratar con el servicio nacional de correos de este país, lo cual le hubiera agregado cantidades industriales de incertidumbre a la ecuación, así que opté por FedEx. Quizás no sea el envío más cómodo del mundo, sobre todo si se hace desde China, pero con un cupón de descuento por ser la primera vez que uso el servicio creo que llegué a un precio con el que me sentía relativamente bien, así que el precio final fue de unos 25 dólares. Nada asombroso pero pudo haber sido mucho peor.</p>
<h2 id="electronic-components-assemble"><em>Electronic components, assemble!</em></h2>
<figure class="width85perc no-responsive no-conversion">
  <img loading="lazy" alt="Finalmente, PCBs impresos" height="487" title="Finalmente, PCBs impresos (*banana for scale*)" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcbs_&amp;_banana.jpg"><figcaption>Finalmente, PCBs impresos (<em>banana for scale</em>)</figcaption></figure><p>El color azul parecía apropiado. &#x1f63a;</p>
<p>El PCB negro no es realmente uno, es una maqueta impresa en 3D para propósitos demostrativos y como reemplazo de los verdaderos mientras llegaban, sobre todo para el siguiente paso: una caja para el circuito.
Era lo más lógico para cuando ya estuviera todo casi listo, no quisiera que la habitación tenga circuitos expuestos que, además de ponerlos en cierto riesgo, desentonan con el resto de la decoración. Felizmente, en <a href="https://www.autodesk.com/products/fusion-360/overview" target="_blank">Fusion 360</a> no es muy complicado diseñar algo medianamente decente para poder imprimir luego.</p>
<figure class="video width85perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/wled-lamp-pcb/enclosure_timelapse.mp4">
</video>
<figcaption>Aprendí a usar <em>sketches</em> a la mitad del diseño, por eso tanto cilindro suelto por ahí</figcaption>
</figure>

<p>Con ayuda del PCB falso podía validar si las tolerancias de mi diseño eran apropiadas. Menos mal no tuve que hacer demasiadas modificaciones una vez listo el modelo final, salvo los agujeros en la tapa en donde van los tornillos.<br>
La idea es la siguiente: la tapa se imprime &ldquo;boca arriba&rdquo; para evitar usar innumerables soportes en toda su extensión, ya que no es uniforme por la parte baja, mientras que sí lo es por arriba. El problema con eso es que, al tener un tope los agujeros para los tornillos, son estos topes los que necesitarían soportes para no ser impresos, literalmente, en el aire. A este tipo de agujeros se les conoce en inglés como <a href="https://en.wikipedia.org/wiki/Counterbore" target="_blank"><em>counterbores</em></a> y en español se le conoce como avellanado al proceso de creación del tope para que los tornillos queden al ras de la superficie.</p>
<figure class="width60perc no-responsive no-conversion">
  <img loading="lazy" alt="Al ser la tapa una pieza cuya parte inferior no toca la cama de la impresora en su gran mayoría, es más fácil, económico y rápido imprimirla rotada 180 grados o &ldquo;boca arriba&rdquo;" height="490" title="Al ser la tapa una pieza cuya parte inferior no toca la cama de la impresora en su gran mayoría, es más fácil, económico y rápido imprimirla rotada 180 grados o \"boca arriba\"" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/lid.png"><figcaption>Al ser la tapa una pieza cuya parte inferior no toca la cama de la impresora en su gran mayoría, es más fácil, económico y rápido imprimirla rotada 180 grados o &ldquo;boca arriba&rdquo;</figcaption></figure><p>Normalmente son agujeros fáciles de hacer con otro tipo de procesos de fabricación, pero no siempre es así con la impresión aditiva, menos aún si se imprime al revés, así que hay que encontrar alternativas.</p>
<figure class="width60perc no-responsive no-conversion">
  <img loading="lazy" alt="Con el tope impreso en el aire" height="540" title="Impreso con el tope en el aire" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/messed_hole.jpg"><figcaption>Impreso con el tope en el aire</figcaption></figure><p>Hasta ahí todo bien, pero yo no quería imprimir soportes, ya que demora un poco más y sentía que alguna manera de hacerlo de forma &ldquo;limpia&rdquo; debía existir.</p>
<p>Tras mucha prueba y error, encontré en el canal <a href="https://www.youtube.com/watch?v=KBuWcT8XkhA" target="_blank">Maker&rsquo;s Muse</a> una técnica para imprimir este tipo orificios llamada <em>sequential bridging</em>, aparentemente inventada por <a href="https://github.com/nophead" target="_blank">nophead</a> como consta en su <a href="https://hydraraptor.blogspot.com/2014/03/buried-nuts-and-hanging-holes.html" target="_blank">blog</a>.</p>
<figure class="width60perc no-responsive no-conversion">
  <img loading="lazy" alt="Así se ve el modelo usando <em>sequential bridging</em> en la parte del agujero" height="565" title="Así se ve el modelo usando *sequential bridging* en la parte del agujero" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/counterbore_model.png"><figcaption>Así se ve el modelo usando <em>sequential bridging</em> en la parte del agujero</figcaption></figure><figure class="width60perc no-responsive no-conversion">
  <img loading="lazy" alt="Usando <em>sequential bridging</em>" height="540" title="Impreso usando *sequential bridging*" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/counterbore_hole.jpg"><figcaption>Impreso usando <em>sequential bridging</em></figcaption></figure><p>Básicamente, la estrategia consta en no dejar que la máquina imprima círculos enteros sin un punto de apoyo, si no en cubrir el mismo espacio en pasos, dos o tres, con cuerdas que tienen puntos de apoyo al inicio y al final y atraviesan distancias cortas, lo cual los vuelve fácilmente imprimibles.</p>
<figure class="video width85perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/wled-lamp-pcb/sequential_bridging.mp4">
</video>
<figcaption>Aquí se aprecia un poco mejor la nueva forma de imprimir esta clase de agujeros</figcaption>
</figure>

<p>Y así luce un tornillo al ras de la superficie superior de la tapa. El método funciona.</p>
<figure class="width60perc no-responsive no-conversion">
  <img loading="lazy" alt="Tornillo al ras de la superficie superior de la tapa" height="540" width="720"src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/screw_flush.jpg"></figure><p class="idea center">* * *</p>
<p>Volviendo al circuito, ahora sí tenía todo listo para soldar y ensamblar.</p>
<figure class="width60perc">
  <img loading="lazy" alt="Componentes pegados con silicona caliente, prefijados para soldar" height="451" title="Componentes pegados con silicona caliente, prefijados para soldar" width="720"srcset="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcb_hot_glue_hu_cbbfaf4b39955c1.webp 480w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcb_hot_glue_hu_e4be15638025a10c.webp 720w ,https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcb_hot_glue_hu_dad38a6541102c5c.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcb_hot_glue_hu_1067192b1e107473.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/wled-lamp-pcb/pcb_hot_glue_hu_1067192b1e107473.webp"><figcaption>Componentes pegados con silicona caliente, prefijados para soldar</figcaption></figure><p>Una vez todos los componentes estuvieron en su respectivo lugar, utilicé silicona caliente para fijarlos temporalmente, así sería más fácil poder soldarlos por la parte posterior. Como nota, <strong>no aplicar tanta silicona como se ve en la fotografía</strong>, luego es un poco difícil quitarla, así que mejor mientras menos se haya aplicado. Fuera de eso parece una alternativa decente, sobre todo si se trabaja en solitario.</p>
<p>Después de un buen rato soldando, este fue el resultado:</p>
<figure class="video width85perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/wled-lamp-pcb/snap_assembly.mp4">
</video>
<figcaption>Ez</figcaption>
</figure>

<p>Ahora sí puedo morir en paz. Para ser sincero, aún hay, muy ocasionalmente, algunos destellos por aquí y por allá, pero estoy convencido de que la causa es el compresor de aire del mini refrigerador que está en esta habitación. Por ahora lo voy a dejar así, no es algo que sea molesto, pero quizás deba empezar a pensar en una suerte de supresor de picos para ese electrodoméstico.</p>
<p>Lo importante, sin embargo, es que funciona, que aprendí muchas cosas en el camino y que me divertí.</p>
<p>Ahora hasta funciona de forma sincronizada con las luces estilo <a href="https://es.wikipedia.org/wiki/Ambilight" target="_blank">Ambilight</a> del televisor. Quizás algún día escriba sobre mi <em>setup</em> televisivo luminoso.</p>
<figure class="video width85perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/wled-lamp-pcb/hyperion_demo.mp4">
</video>
<figcaption>Y, para coronar, los Warriors ganando, realmente hermoso</figcaption>
</figure>

]]></content:encoded>
    </item>
    <item>
      <title>Me pasé al lado oscuro de las impresoras 3D y es excelente 😁</title>
      <link>https://inobtenio.com/posts/dark-side-3d-printing/</link>
      <pubDate>Wed, 27 Aug 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/dark-side-3d-printing/</guid>
      <description>Yo siempre quise tener una Prusa, pero me doy cuenta de que tener una Bambu Lab no está mal, para nada mal.</description><enclosure url="https://inobtenio.com/img/posts/dark-side-3d-printing/bbla1_box.jpg" length="0" type="image/jpeg" />
      <content:encoded><![CDATA[<p>A ver, tampoco está tan tenebroso por aquí, podría ser bastante peor, aunque se siente el lado oscuro de la fuerza rondando.</p>
<h2 id="la-excusa">La excusa</h2>
<p>Hace unos meses quería cambiar unas lámparas de iluminación indirecta que yo mismo había armado con un par de LEDs y unos tubos de cartón, quería que fueran un poco más flexibles en cuanto a los colores que podían mostrar; aparte, quería que fueran un poco más elegantes dentro de lo posible, digamos que el cartón no es el material más noble.<br>
Por algún motivo, ni siquiera me molesté en buscar alternativas comerciales disponibles, supuse desde el principio que mi situación era un poco específica.</p>
<p>Para que la idea quede un poco más clara, necesitaba un par de cosas de estas lámparas:</p>
<ul>
<li>Que se vean &ldquo;bien&rdquo; y &ldquo;elegantes&rdquo;, lo cual es determinado principalmente por  mi cuestionable sentido del gusto y de la clase.</li>
<li>Que puedan ser fabricadas, en su totalidad, en casa.</li>
<li>Que alumbren con luz de colores, no únicamente blanca.</li>
<li>Que estén diseñadas para ser pegadas a una superficie, asegurarlas con tornillos estaría bien como opción pero necesito adherirlas.</li>
<li>Que sean compatibles con <a href="https://kno.wled.ge/" target="_blank">WLED</a>, que es un <em>webserver</em> de control de LEDs <a href="https://www.adafruit.com/category/168?srsltid=AfmBOorfpeyH8XDFcztOqUujuZ8EWcTy3GQapZE98ELKPC7cAKUmA3VW" target="_blank">NeoPixel</a>, no necesariamente una nueva tecnología de televisores.</li>
</ul>
<figure class="width60perc">
  <img loading="lazy" alt="Primera versión; o sea, la anterior" height="970" title="Primera versión; o sea, la anterior" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_v1_hu_457e5271c0342102.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_v1_hu_de0ac9b52637b182.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_v1_hu_68e42af8e27c5a7f.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_v1_hu_30e9040f7a1b90fc.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_v1_hu_30e9040f7a1b90fc.webp"><figcaption>Primera versión; o sea, la anterior</figcaption></figure><p>La primera versión, la que está entrando en jubilación, sólo cumplía, en su totalidad, con los requerimientos 2 y 4 y con el primero de forma peligrosamente parcial; además, había escogido LEDs demasiado potentes para este uso y el calor generado por ellos había derretido un poco los soportes impresos que los sostenían. Era tiempo de una renovación.</p>
<p>Fue fácil decidir que serían lámparas impresas tridimensionalmente, al fin y al cabo creí que podía diseñar unas piezas de baja complejidad sin demasiados problemas y que en unos días el proyecto podría estar terminado. Creo que no estaba particularmente cerca de tener razón.</p>
<p>Con la ayuda de <a href="https://www.blender.org/" target="_blank">Blender</a> —cuyo uso no recomiendo mucho para piezas mecánicas o del ámbito de la ingeniería, para eso mejor algo como <a href="https://www.autodesk.com/mx/products/fusion-360/overview" target="_blank">Fusion</a>— llegué a un diseño con el que estaba conforme. Si todo salía bien el diseño permitiría el cumplimiento de todos los requisitos.</p>
<p>La idea es simple, es un conjunto de cuatro piezas:</p>
<ul>
<li>La parte de color naranja es la principal, una de las que van ancladas a una pared o columna y ofrece algunas opciones de manejo de cableado pero, sobretodo, soporte para dos segmentos cortos de tiras LED; 7 módulos LED <a href="https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf" target="_blank">WS2812B</a> por lado con una separación de 1 cm, más o menos.</li>
<li>La parte de color morado es la pantalla, la cual impide el paso de la luz hacia el frente y los lados.</li>
<li>Las partes superior e inferior de color verde son los los difusores, que se encargan de suavizar la luz emitida por las tiras al ser impresos en filamento transparente.</li>
<li>Las partes azules son una representación de las tiras LED con fines estéticos.</li>
</ul>




<figure>
  <div class="model-container" id="model-container-lamp"></div>
  <figcaption>Internet points para el que adivine a qué hace referencia la paleta de colores de esta maqueta</figcaption>
</figure>

<script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.179.1/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.179.1/examples/jsm/"
    }
  }
</script>

<script type="module">

  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls';
  import { DragControls } from 'three/addons/controls/DragControls';
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
  import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
  import { MTLLoader  } from 'three/addons/loaders/MTLLoader.js';
  import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

  const modelId = "lamp";
  const modelSrc = "\/special\/posts\/dark-side-3d-printing\/fixed.glb";
  const elem = document.getElementById(`model-container-${modelId}`);
  const scale =  1.3 ;
  const drag = "yes";

  elem.style.filter = 'saturate(0.55)';

  var conf = {
      movement: false,
      rotation: false,
      reset: function() { resetWorld() }
  }

  var gui = new GUI({container: elem});
  
  gui.add(conf, 'rotation').name('Rotate');
  gui.add(conf, 'reset').name('Reset');
  gui.close();

  const loader = new GLTFLoader();

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.setPixelRatio( window.devicePixelRatio );

  THREE.ColorManagement.legacyMode = false;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf1f1f1);

  scene.add(new THREE.AxesHelper(5));

  const ambientLight = new THREE.AmbientLight( 0xffffff, 1.8 );
  scene.add(ambientLight);

  const lightGroup = new THREE.Group();
  const positions = [
    [ 0,  10,  0],  

    [ 10,  0,  0],  
    [-10,  0,  0],  
    [ 0,   0, 10],  
    [ 0,   0,-10],  
  ]

  positions.forEach(([x, y, z]) => {
    const point = new THREE.DirectionalLight(0xffffff,  1.2 );
    point.position.set(x, y, z);
    lightGroup.add(point);
  });

  scene.add(lightGroup);

  const camera = new THREE.PerspectiveCamera(20, 16/9, 1, 1000);
  camera.position.set(40, 35, 50);
  camera.lookAt(new THREE.Vector3());
  scene.add(camera);

  const controls = new OrbitControls(camera, renderer.domElement);

  controls.enableDamping = true;
  controls.rotateSpeed = 0.25;
  controls.dampingFactor = 0.2;
  controls.enableZoom = true;
  controls.autoRotate = false;

  var objects = []

  let t = 0;
  let count = 0;
  let objectIndex = 0;

  let vectorMultiplier = 0.002;
  let animationDistanceFactor = 1;
  let animationDistance = animationDistanceFactor/vectorMultiplier;

  var move = function(count, mesh, vector, distance) {
    if (vector && distance) {
      var fff = vectorMultiplier*distance;
      if (count > animationDistance) fff = -fff;
      mesh.position.add(new THREE.Vector3(...vector).multiplyScalar(fff))
    }
  }

  var colors, opacities, vectors, distances;

  var load = function(filename) {
    loader.load(filename, function(object) {

			const model = object.scene;
			const obj = model.children[0];

			model.traverse((child) => {
				if (child.isMesh && child.material) {
					
					
					objects.push(child);

					child.userData.initialPosition = child.position.clone();

					child.material = child.material.clone();

					objectIndex++;
				}
			});

			model.scale.setScalar(scale);
      model.position.y = -2;
      scene.add(model);

      


      function animate() {
        requestAnimationFrame(animate);

        if (conf.movement && vectors && distances) {
          if (conf.movement && count < 2*animationDistance) {
            count++;
            objects.forEach(function(obj, index) {
              move(count, obj, vectors[index], distances[index]);
            })
          }
        }

        if (count >= 2*animationDistance) {
          count = 0;
          conf.movement = false;
        }

        if (conf.rotation) {
          controls.autoRotate = true;
          controls.autoRotateSpeed = 0.25;
        } else {
          controls.autoRotate = false;
        }

        controls.update();
        renderer.render(scene, camera);
      }

      elem.appendChild(renderer.domElement);
      animate();
    }, undefined, function(error) {
        console.error(error);
    });
  }

  var resetWorld = function() {
    count = 0;
    controls.reset();
    objects.map(function(o) {
      o.position.copy(o.userData.initialPosition);
      t = 0;
      conf.movement = false;
    })
  }

	load(modelSrc);

  var vectors = [[1,0,0], [0,1,0], [0,-1,0], [-1,0,0], [0,1,0], [0,-1,0]];
  var distances = [5, 4, 4, 3, 2.5, 2.5];

  if (drag) {
    const controls2 = new DragControls(objects, camera, renderer.domElement);

    controls2.addEventListener( 'dragstart', function ( event ) {

      conf.movement = false;
      event.object.material.emissive.set( 0xaaaaaa );

    } );

    controls2.addEventListener( 'dragend', function ( event ) {

      event.object.material.emissive.set( 0x000000 );

    } );
  }

</script>

<p>Con todo listo copié los archivos a una tarjeta MicroSD que puse en mi queridísima y veterana <a href="https://www.creality.com/products/ender-3-3d-printer" target="_blank">Ender 3</a> para imprimir la pantalla. 12 horas y media era lo que demoraría la pieza en estar lista y lo que demoraría yo también en saber si finalmente mi diseño servía para algo, así que la dejé imprimiendo y me fui a la cama.</p>
<p>La mañana siguiente (sí, aparentemente duermo 12 horas) no iba a ser la más grata de mi vida, lamentablemente. Lo que encontré fue algo tristemente muy similar a esto:</p>
<figure class="width60perc">
  <img loading="lazy" alt="Había visto cosas espantosas imprimiendo objetos, pero nunca algo así" height="463" title="Había visto cosas espantosas imprimiendo objetos, pero nunca algo así" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mesh_v3_hu_83a51f939722b600.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mesh_v3_hu_160e2916bd603b75.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mesh_v3_hu_1b251cb32216fd7.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mesh_v3_hu_10925ad8e6f749c6.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mesh_v3_hu_10925ad8e6f749c6.webp"><figcaption>Había visto cosas espantosas imprimiendo objetos, pero nunca algo así</figcaption></figure><p>Después de todas las obvias preguntas que me hice al ver semejante abominación en la impresora, ninguna con una obvia respuesta en ese momento, hice varios intentos más cambiando casi todo lo que se puede cambiar en este tipo de procesos. Cambié la configuración del <a href="https://en.wikipedia.org/wiki/Slicer_%283D_printing%29" target="_blank"><em>slicer</em></a> que usaba (<a href="https://ultimaker.com/es/software/ultimaker-cura/" target="_blank">Cura</a>), nada; cambié de <a href="https://www.prusa3d.com/page/prusaslicer_424/" target="_blank"><em>slicer</em></a>, nada; recalibré la impresora, nada; usé un <a href="https://prusament.com/es/" target="_blank">filamento diferente</a>, nada.<br>
No importaba qué ni cuánto lo cambiara, la impresión resultaba igual o peor que en la fotografía anterior.</p>
<figure class="width50perc">
  <img loading="lazy" alt="Eventualmente, a la hora de desarmarla, encontré el posible problema, el extrusor <strong>sucio</strong> y roto" height="974" title="Eventualmente, a la hora de desarmarla, encontré el posible problema, el extrusor **sucio** y roto" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_broken_extruder_hu_141732bda38f9550.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_broken_extruder_hu_b53eeaa4b4f8bc38.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_broken_extruder_hu_5a8310d2b6d4406b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_broken_extruder_hu_6af1f074c22098.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_broken_extruder_hu_6af1f074c22098.webp"><figcaption>Eventualmente, a la hora de desarmarla, encontré el posible problema, el extrusor <strong>sucio</strong> y roto</figcaption></figure><p>La Ender 3 me ha acompañado bastante bien y me ha ayudado mucho a aprender sobre la impresión en 3D, es una máquina de potencial bastante decente por el precio, pero claro, el potencial no siempre se traduce en realidad. Tiene problemas de confiabilidad y hay que estar muy atento a todas sus necesidades antes de que pueda ser un artefacto de simplemente prender y usar. Requiere una cantidad de trabajo no menor.<br>
Sin embargo, su enorme popularidad no miente, era, en sus buenos años, <strong>la</strong> impresora para comenzar.</p>
<p>Aún teniendo en cuenta que la tengo desde 2018, llevaba unos meses queriendo hace un pequeño <em>upgrade</em> pero no lograba convencerme. No era estrictamente necesario, pocas veces lo es, no imprimo 3 piezas por día como para que se vuelva algo obligatorio comprar una máquina más capaz (y vaya que han aparecido varias de esas en estos años), pero decidí tomar este pequeño desastre como una señal para apretar finalmente el gatillo e ir por una impresora superior.</p>
<p>En los meses que llevo coqueteando con la idea de comprar una nueva he estado viendo y leyendo mucho sobre el estado actual del mercado y una marca (y una impresora en específico) llamó mi atención poderosamente: <a href="https://www.prusa3d.com/" target="_blank">Prusa</a>.<br>
Para ser más concreto, me emocionaba la idea de ser el dueño de una <a href="https://www.prusa3d.com/product/original-prusa-mk4s-3d-printer/" target="_blank">MK4S</a>, tiene una pinta excelente, es rapidísima, confiable, puede ser operada remotamente y, lo mejor de todo, es de código abierto.</p>
<figure class="width50perc">
  <img loading="lazy" alt="Prusa MK4S" height="720" title="Prusa MK4S" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mk4s_hu_b6380f1f381a2684.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mk4s_hu_d7de60850e1af8d0.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mk4s_hu_4a1ecf53b5065c13.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mk4s_hu_45def9cec71f3257.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/mk4s_hu_45def9cec71f3257.webp"><figcaption>Prusa MK4S</figcaption></figure><p>Okay, okay, ya sé, no, Prusa técnicamente ya no produce impresoras de código abierto, por lo menos no en su totalidad, pero muchos de los componentes tanto de <em>hardware</em> como de <em>software</em> sí lo son; aparte, no requieren una cuenta o conectarse con sus servidores para usar la impresora. Además de eso, ha sido un agente importante en el desarrollo de la escena de la impresión 3D tanto como <em>hobby</em> como en ambientes más profesionales, así que le debemos de alguna manera algunas de las innovaciones que hoy en día son moneda común.</p>
<p>Y por si todo eso fuera poco, el color de acento de sus productos es un naranja muy sexy. No sé, para mí eso suma puntos.</p>
<p>Bueno, tomada la decisión, sólo era cuestión de ir a la página y verificar si hacían envíos a mi área. Ja.</p>
<figure class="half">
  <img loading="lazy" alt="Para la versión kit (o sin ensamblar)" height="1417" title="Para la versión *kit* (o sin ensamblar)" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_kit_hu_560e5170b8eaef2e.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_kit_hu_41248658c754486a.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_kit_hu_db9de3b536a3ce68.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_kit_hu_b732fb8de8e6caa6.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_kit_hu_b732fb8de8e6caa6.webp"><figcaption>Para la versión <em>kit</em> (o sin ensamblar)</figcaption></figure><figure class="half">
  <img loading="lazy" alt="Para la versión ensamblada" height="1389" title="Para la versión ensamblada" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_assembled_hu_417c06dfafd4b02.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_assembled_hu_7b9d88453e41a936.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_assembled_hu_c214498569e0d294.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_assembled_hu_5df5264467549794.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/prusa_shipping_assembled_hu_5df5264467549794.webp"><figcaption>Para la versión ensamblada</figcaption></figure><p>Y todos esos dólares no toman en cuenta el proceso y costo de desaduanaje, el cual podría incrementar el total hasta en 20%, dependiendo de cómo se apliquen las reglas tributarias. Creo que son precios un tanto difíciles de justificar.</p>
<h2 id="una-vez-descartada-la-prusa">Una vez descartada la Prusa&hellip;</h2>
<p>Bueno, después de matar la Prusa, como diría Karol G, era momento de mirar hacia otro lado. Lamentablemente, o quizás no tanto, la siguiente mejor opción era una <a href="https://bambulab.com/en/a1" target="_blank">Bambu Lab A1</a>.<br>
Según mis averiguaciones, una de las mejores impresoras del mercado en el segmento de entrada: rápida, precisa, confiable y, sobretodo, barata (comparada con sus rivales, claro).</p>
<p>La A1 tiene varias ventajas frente a la MK4S, como el hecho de venir de fábrica con una cámara (que va a menos de 1 fps y cuyo <em>feed</em> sólo es observable a través de las <em>apps</em> de Bambu Lab, eso sí) y una pequeña lámpara, ser más fácil de ensamblar, tener una comunidad aún más grande aunque quizás no tan técnica y ser significativamente más barata. Definitivamente facilitan la toma de decisiones.</p>
<p>El único problema real era la compañía detrás de tan buen producto, Bambu Lab, y no porque esté llena de malos profesionales (se formó originalmente a partir de un grupo de extrabajadores de DJI, después de todo), si no porque a inicios de este año decidieron llevar a cabo una jugada bastante desleal con sus usuarios y estuvieron a punto de requerir marcas de filamento específicas y hasta suscripciones para usar ciertas funciones de sus impresoras, entre otras cosas.
El asunto fue bastante discutido en <a href="https://www.reddit.com/r/prusa3d/comments/1i3rrz7/the_controversy_regarding_bambu_lab_and_their_new/" target="_blank">ciertos espacios</a> de internet y la empresa hizo algunos cambios significativos a su plan en respuesta, aunque el panorama sigue sin pintar demasiado bien. En <a href="https://www.theverge.com/2025/1/21/24349031/bambu-3d-printer-update-authentication-filament-subscription-lock-answers" target="_blank">este artículo</a> de The Verge explican bastante mejor que yo el problema.</p>
<p>Aún así, no deja de ser un gran aparato; claro, en contraste a cómo maneja Prusa estos menesteres la diferencia es grande y hasta desagradable, pero en realidad no están haciendo nada que no haga la gran mayoría de empresas que proveen servicios atados a sus productos, como empresas de lavadoras, hornos microondas, teléfonos o autos. Para la gran mayoría de personas, incluyéndome, no es un verdadero problema que haya que crear una cuenta o descargar un programa específico, pero para un puñado eso se siente como una suerte de invasión, especialmente en un rubro en el que históricamente el conocimiento, el código y hasta el <em>hardware</em> se han compartido de forma tan abierta.<br>
Es evidente que la impresión 3D ya no sólo les es accesible e interesante a unos cuantos y las impresoras están alcanzando el nivel de <a href="https://en.wikipedia.org/wiki/Commodity" target="_blank"><em>commodities</em></a>.</p>
<p>Aún después de toda esa queja decidí que no era tan mala idea ir por una A1. Me sentía un traidor, claro, pero las necesidades (y la comodidad) muchas veces pueden más. Era hora de la prueba de fuego, el proceso de envío. Ja.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Sólo hacen envíos a Estados Unidos" height="424" title="Sólo hacen envíos a Estados Unidos" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_shipping_hu_de5fc5f5b46cbc5b.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_shipping_hu_2527cb3c67a2dca1.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_shipping_hu_38022a3a99dfc1b8.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_shipping_hu_43e4042f6f630a11.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_shipping_hu_43e4042f6f630a11.webp"><figcaption>Sólo hacen envíos a Estados Unidos</figcaption></figure><p>Bambu Lab tiene tiendas oficiales para otras regiones, más específicamente en Asia, la Unión Europea, Reino Unido, Australia, Canadá, Japón y Corea del Sur, pero nada de América del Sur. Al igual que con el costo de envío de las Prusa, consecuencias de vivir en el tercer mundo.</p>
<p>Menos mal tienen listas de distribuidores autorizados en regiones donde no están presentes de manera oficial y encontré una tienda en mi ciudad. Al final pagué menos por la A1 incluyendo el sistema de filamentos múltiples de lo que habría pagado por la MK4S <strong>sin ensamblar y sin dicho sistema</strong>. Una locura.</p>
<h2 id="ahora-sí-juguete-nuevo">Ahora sí, juguete nuevo</h2>
<p>Era hora de jubilar a la entrañable Ender 3 y darle paso a la juventud, al ímpetu, a la sangre nueva. ¡Los viejos a la tumba, los jóvenes a la obra! Así era, ¿no?</p>
<figure class="half">
  <img loading="lazy" alt="Creality Ender 3" height="982" title="Creality Ender 3" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_setup_hu_6ef759193b83ea47.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_setup_hu_68b419572b06d1d5.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_setup_hu_2b86e1ecc923d743.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_setup_hu_9f449d526e9045a3.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/ender_3_setup_hu_9f449d526e9045a3.webp"><figcaption>Creality Ender 3</figcaption></figure><figure class="half">
  <img loading="lazy" alt="Bambu Lab A1" height="988" title="Bambu Lab A1" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_a1_setup_hu_919f5f4472518603.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_a1_setup_hu_7d07322f7d383c23.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_a1_setup_hu_b069a68e334da0d4.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_a1_setup_hu_8963fea7ab3b32be.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/bambu_a1_setup_hu_8963fea7ab3b32be.webp"><figcaption>Bambu Lab A1</figcaption></figure><p>Para dejarlo claro, la A1 tampoco vino armada, no del todo por lo menos, literalmente son dos o tres piezas grandes que se juntan con algunos tornillos y ya está. Es un proceso que debería demorar menos de 30 minutos, probablemente.
Es más, para dar un poco más de contexto, demoré bastante más desarmando la Ender 3 que armando la A1.</p>
<p>De todos modos, dejo por aquí algunos videos, tanto del <a href="/video/posts/dark-side-3d-printing/ender_3_disassembly.mp4" target="_blank">desarmado de la antigua</a> como del <a href="/video/posts/dark-side-3d-printing/a1_assembly.mp4" target="_blank">armado de la nueva</a>.</p>
<p>¿la diferencia? El día y la noche. La A1 no sólo es más rápida, bastante más rápida, entre 3 y 4 veces más en condiciones y configuración estándar, si no que también es más confiable y por fin puedo enviar modelos para imprimir &ldquo;a través del aire&rdquo; y no tengo que acercarme necesariamente llevando los archivos en una tarjeta de memoria. También es bastante sexy, más que la MK4S inclusive.</p>
<figure class="video width40perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/dark-side-3d-printing/benchy.mp4">
</video>
<figcaption>Está imprimiendo especialmente rápido pero el video no está acelerado</figcaption>
</figure>

<p>Lo único que me resulta un poco raro es que al momento de ordenarle que imprima algo, en la etapa de precalentamiento y preparación el ritmo al que consume energía supera los 1000 vatios momentáneamente, como se puede ver en la siguiente imagen, pero luego todo dentro de lo normal.</p>
<figure class="width85perc no-conversion no-responsive">
  <img loading="lazy" alt="Tasa de consumo energético a lo largo de una impresión de unos 60 minutos. Gráfico generado en Grafana con ayuda de un S31." height="206" title="Tasa de consumo energético a lo largo de una impresión de unos 60 minutos. Gráfico generado en [Grafana](https://grafana.com/) con ayuda de un [S31](https://sonoff.tech/products/sonoff-s31-s31-lite-compact-design-smart-plug-with-energy-monitoring-us-type?variant=46172683141361)." width="720"src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_power_g.png"><figcaption>Tasa de consumo energético a lo largo de una impresión de unos 60 minutos. Gráfico generado en <a href="https://grafana.com/" target="_blank">Grafana</a> con ayuda de un <a href="https://sonoff.tech/products/sonoff-s31-s31-lite-compact-design-smart-plug-with-energy-monitoring-us-type?variant=46172683141361" target="_blank">S31</a>.</figcaption></figure><figure class="width85perc no-conversion no-responsive">
  <img loading="lazy" alt="Consumo total a lo largo de una impresión de unos 60 minutos. Gráfico generado en Grafana con ayuda de un S31." height="206" title="Consumo total a lo largo de una impresión de unos 60 minutos. Gráfico generado en Grafana con ayuda de un S31." width="720"src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_energy_g.png"><figcaption>Consumo total a lo largo de una impresión de unos 60 minutos. Gráfico generado en Grafana con ayuda de un S31.</figcaption></figure><p>No tuve oportunidad de medirlo, pero por lo que pude leer, el consumo energético medio de la Ender 3 en una hora es de unos 0.12 kWh, por lo que la A1 y sus 0.15 kWh estarían un 25% por encima. Considerando que para una impresión cualquiera ésta última puede demorar un 66% menos, terminaría siendo hasta 150% más eficiente que la Ender 3 y con mejor calidad. Una diferencia abismal.<br>
Claro, si tomamos en cuenta el importante <em>spike</em> energético al inicio de la impresión en el caso de la A1, cuanto más filamento se imprima, más eficiente terminará siendo, ya que ese pico es menos representativo mientras más tiempo pase. En comparación, si la impresión hubiera tardado 30 minutos en vez de 60, la A1 habría resultado &ldquo;sólo&rdquo; un 100% más eficiente.</p>
<p>Pero ¿recuerdan que había comprado el combo que incluía el sistema de filamento múltiple? Era hora de armarlo, quería poder usar PETG como interfaz de soporte al momento de imprimir con PLA, que es casi siempre, es una combinación que <a href="https://www.reddit.com/r/BambuLab/comments/1idnfyi/plapetg_support_interface_layer_demonstration/" target="_blank">ha probado</a> dar buenos resultados cuando se trata de separar los soportes de las partes importantes de los objetos impresos. El problema es que no tenía espacio en mi rincón 3D para semejante soporte, tuve que buscar una alternativa. Felizmente, <a href="https://makerworld.com/es/@arzhanglotfi" target="_blank">@arzhanglotfi</a> en <a href="https://makerworld.com/es" target="_blank">MakerWorld</a> hizo un <a href="https://makerworld.com/es/models/707336-ams-lite-a1-top-mount-no-tilt-remix#profileId-637387" target="_blank"><em>remix</em></a> de un modelo original de Bambu Lab para anclar el módulo a la parte superior de la impresora, ocupando espacio extra sólo en el eje Z y le quedó de rechupete.</p>
<figure class="width50perc">
  <img loading="lazy" alt="A1 con el módulo de filamento múltiple" height="1036" title="A1 con el módulo de filamento múltiple" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_ams_lite_hu_33d36fd5ac84bf16.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_ams_lite_hu_c961c4a68f7573de.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_ams_lite_hu_c0786ee38610bde5.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_ams_lite_hu_c787905d65aa09b6.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/a1_ams_lite_hu_c787905d65aa09b6.webp"><figcaption>A1 con el módulo de filamento múltiple</figcaption></figure><p>Ahora sí, con todo en su sitio, me puse a imprimir unos <a href="https://makerworld.com/en/models/32785-rackstud-duo-1u#profileId-29437" target="_blank"><em>clips</em> para gabinetes de servidores</a> para colgar algunas cositas ligeras, así como unos <a href="https://makerworld.com/en/models/1085975-modern-desk-clamp-with-hook" target="_blank">pequeños ganchos</a> para colgar juguetitos en las repisas, todo sin sobresaltos.</p>
<figure class="width60perc">
  <img loading="lazy" alt="Las partes naranjas no son parte de la impresión" height="489" title="Las partes naranjas no son parte de la impresión" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/first_prints_hu_82bb89d50234d0a7.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/first_prints_hu_de158edbde7f80c6.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/first_prints_hu_c8a3af391e39ef7b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/first_prints_hu_598d28bc25104807.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/first_prints_hu_598d28bc25104807.webp"><figcaption>Las partes naranjas no son parte de la impresión</figcaption></figure><!-- 



<figure>
  <div class="model-container" id="model-container-wled"></div>
  <figcaption>Internet points para el que adivine a qué hace referencia la paleta de colores de esta maqueta</figcaption>
</figure>

<script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.179.1/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.179.1/examples/jsm/"
    }
  }
</script>

<script type="module">

  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls';
  import { DragControls } from 'three/addons/controls/DragControls';
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
  import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
  import { MTLLoader  } from 'three/addons/loaders/MTLLoader.js';
  import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

  const modelId = "wled";
  const modelSrc = "\/special\/posts\/dark-side-3d-printing\/WLED.obj";
  const elem = document.getElementById(`model-container-${modelId}`);
  const scale =  3.5 ;
  const drag = "";

  elem.style.filter = 'saturate(1.15)';

  var conf = {
      movement: false,
      rotation: false,
      reset: function() { resetWorld() }
  }

  var gui = new GUI({container: elem});
  
  gui.add(conf, 'rotation').name('Rotate');
  gui.add(conf, 'reset').name('Reset');
  gui.close();

  const loader = new GLTFLoader();

  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.setPixelRatio( window.devicePixelRatio );

  THREE.ColorManagement.legacyMode = false;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf1f1f1);

  scene.add(new THREE.AxesHelper(5));

  const ambientLight = new THREE.AmbientLight( 0xffffff, 1.8 );
  scene.add(ambientLight);

  const lightGroup = new THREE.Group();
  const positions = [
    [ 0,  10,  0],  

    [ 10,  0,  0],  
    [-10,  0,  0],  
    [ 0,   0, 10],  
    [ 0,   0,-10],  
  ]

  positions.forEach(([x, y, z]) => {
    const point = new THREE.DirectionalLight(0xffffff,  4.5 );
    point.position.set(x, y, z);
    lightGroup.add(point);
  });

  scene.add(lightGroup);

  const camera = new THREE.PerspectiveCamera(20, 16/9, 1, 1000);
  camera.position.set(40, 35, 50);
  camera.lookAt(new THREE.Vector3());
  scene.add(camera);

  const controls = new OrbitControls(camera, renderer.domElement);

  controls.enableDamping = true;
  controls.rotateSpeed = 0.25;
  controls.dampingFactor = 0.2;
  controls.enableZoom = true;
  controls.autoRotate = false;

  var objects = []

  let t = 0;
  let count = 0;
  let objectIndex = 0;

  let vectorMultiplier = 0.002;
  let animationDistanceFactor = 1;
  let animationDistance = animationDistanceFactor/vectorMultiplier;

  var move = function(count, mesh, vector, distance) {
    if (vector && distance) {
      var fff = vectorMultiplier*distance;
      if (count > animationDistance) fff = -fff;
      mesh.position.add(new THREE.Vector3(...vector).multiplyScalar(fff))
    }
  }

  var colors, opacities, vectors, distances;

  var load = function(filename) {
    loader.load(filename, function(object) {

			const model = object.scene;
			const obj = model.children[0];

			model.traverse((child) => {
				if (child.isMesh && child.material) {
					
					
					objects.push(child);

					child.userData.initialPosition = child.position.clone();

					child.material = child.material.clone();

					objectIndex++;
				}
			});

			model.scale.setScalar(scale);
      model.position.y = -2;
      scene.add(model);

      


      function animate() {
        requestAnimationFrame(animate);

        if (conf.movement && vectors && distances) {
          if (conf.movement && count < 2*animationDistance) {
            count++;
            objects.forEach(function(obj, index) {
              move(count, obj, vectors[index], distances[index]);
            })
          }
        }

        if (count >= 2*animationDistance) {
          count = 0;
          conf.movement = false;
        }

        if (conf.rotation) {
          controls.autoRotate = true;
          controls.autoRotateSpeed = 0.25;
        } else {
          controls.autoRotate = false;
        }

        controls.update();
        renderer.render(scene, camera);
      }

      elem.appendChild(renderer.domElement);
      animate();
    }, undefined, function(error) {
        console.error(error);
    });
  }

  var resetWorld = function() {
    count = 0;
    controls.reset();
    objects.map(function(o) {
      o.position.copy(o.userData.initialPosition);
      t = 0;
      conf.movement = false;
    })
  }

	load(modelSrc);

  var vectors = [[1,0,0], [0,1,0], [0,-1,0], [-1,0,0], [0,1,0], [0,-1,0]];
  var distances = [5, 4, 4, 3, 2.5, 2.5];

  if (drag) {
    const controls2 = new DragControls(objects, camera, renderer.domElement);

    controls2.addEventListener( 'dragstart', function ( event ) {

      conf.movement = false;
      event.object.material.emissive.set( 0xaaaaaa );

    } );

    controls2.addEventListener( 'dragend', function ( event ) {

      event.object.material.emissive.set( 0x000000 );

    } );
  }

</script>
 -->
<h2 id="de-vuelta-a-la-lámpara">De vuelta a la lámpara</h2>
<p>La pieza más grande demoró un poco menos de 4 horas (en vez de 12 en la Ender 3) en ser impresa y quedó bastante bien. No libre de errores, sin embargo.<br>
Debí haber previsto que una pieza de base tan amplia y plana para evitar la ligera curvatura que se formó abajo, pero después de eso todo excelente.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Casi todas las piezas impresas, el soporte para las tiras LED se estaba terminando de fabricar" height="407" title="Casi todas las piezas impresas, el soporte para las tiras LED se estaba terminando de fabricar" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_assembled_hu_e4816f446bc21639.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_assembled_hu_e0ef2d26b177a0eb.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_assembled_hu_49900e103c9a2188.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_assembled_hu_df7a2ad2491e584a.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/lamp_assembled_hu_df7a2ad2491e584a.webp"><figcaption>Casi todas las piezas impresas, el soporte para las tiras LED se estaba terminando de fabricar</figcaption></figure><p>Y más o menos así se ve en funcionamiento:</p>
<figure class="video width40perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/dark-side-3d-printing/lamp_working.mp4">
</video>
<figcaption>Flash! A-ah!</figcaption>
</figure>

<p>Esta nueva versión otorga una flexibilidad importante debido al uso de WLED como <em>software</em> de control, puedo escoger entre decenas de efectos y hasta existe la posibilidad de usas estas lámparas como una extensión de mi propia versión de <a href="https://es.wikipedia.org/wiki/Ambilight" target="_blank">Ambilight</a> para iluminar las columnas con los colores de las imágenes que se reproducen en la tele en todo momento. Exquisito.</p>
<p>Todo bien, todo lindo, pero con cierta frecuencia se pueden apreciar unos destellos que arruinan la experiencia. En el video, en unos pocos segundos, se puede apreciar un par, pero a lo largo del día son innumerables y la situación empeora cuando se usa efectos con mayor dinamismo. En el caso de un color estático la cantidad de destellos por unidad de tiempo se ve reducida considerablemente.<br>
Tengo una idea (o quizás un poco más que eso) de lo que puede estar sucediendo, así que mi configuración de WLED debe cambiar para deshacerme de tan molesto fenómeno.</p>
<p>Por el momento, una solución mínimamente elegante parece requerir una pieza que más o menos luce así por detrás:</p>
<figure class="width60perc">
  <img loading="lazy" alt="PCB" height="443" title=":smirk: :smirk: :smirk:" width="720"srcset="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/WLED_PCB_only_blue_hu_28a3c85ecef66596.webp 480w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/WLED_PCB_only_blue_hu_39eb5f3930dd6ef5.webp 720w ,https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/WLED_PCB_only_blue_hu_c3b15bdaadde6492.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/WLED_PCB_only_blue_hu_52817e80fde3dafc.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dark-side-3d-printing/WLED_PCB_only_blue_hu_52817e80fde3dafc.webp"><figcaption>&#x1f60f; &#x1f60f; &#x1f60f;</figcaption></figure><p>Pero el proceso será descrito en detalle, cual <em>youtuber</em>, en un próximo video.</p>
<h2 id="entonces-pertenece-realmente-la-a1-al-lado-oscuro">Entonces, ¿pertenece realmente la A1 al lado oscuro?</h2>
<p>Bueno, sí y no. Por supuesto, hubiera preferido conseguir una Prusa MK4S pero, como expliqué más arriba, el precio y las complicaciones en la importación hicieron que mirara hacia el mercado local y optara por una A1. ¿me arrepiento? Yo diría que no y no con poca convicción.</p>
<p>Si bien Bambu Lab tiene políticas algo restrictivas y volubles, la impresora no deja de ser fantástica. Es cierto, en cualquier momento pueden quedar en bancarrota o apagar sus servidores y yo ya no podré enviar archivos para imprimir desde mi teléfono o ver desde cualquier lugar el estado de una impresión en tiempo real (o la convierten en una característica de pago), quizás un día un par de personas encargadas de cierto tipo de decisiones despiertan de mal humor y deciden que, ahora sí, no dejarán que se use filamentos de terceros en sus impresoras o tal vez sufran en algún punto de una filtración de datos privados de los usuarios, no lo sé, pero todo eso no los hace peores que compañías gigantescas cuyos productos usamos a diario y cuyos potenciales (o actuales) problemas ni siquiera entran en la consideración al comprarlos.</p>
<p>Además, existe el <a href="https://wiki.bambulab.com/en/knowledge-sharing/enable-lan-mode" target="_blank">LAN Mode</a> que, claramente, no es igual de conveniente pero es bastante más seguro, privado e independiente de la empresa. La impresora puede funcionar tranquilamente sin una conexión a internet pero se pierden características que la convierten en una mejor herramienta o, por lo menos, una más fácil de usar. He ahí el asunto, en el <em>trade-off</em> entre conveniencia y control.</p>
<p>Mi punto es que podría ser mejor pero tampoco me puedo quejar demasiado, no todo puede ser (y no es) tan <em>open</em> como me gustaría, pero es un modelo totalmente válido y ciertamente mayoritario. A veces hay que intentar jugar un poquito más de acuerdo a las reglas y quejarse un poquito menos.</p>
<p>Recomendada ampliamente. Por unos 500 verdes (maldito impuesto del subdesarrollo) <strong>difícilmente</strong> se encuentra algo mejor.</p>
<p>Desde luego, todos los modelos mostrados en este artículo sí son libres y están disponibles para descarga y/o modificación en <a href="https://github.com/Inobtenio/3D/tree/master/Sconces" target="_blank">GitHub</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Ahora sí, el año de Linux en el escritorio gracias a... ¿PewDiePie?</title>
      <link>https://inobtenio.com/posts/linux-year-pewdiepie/</link>
      <pubDate>Mon, 30 Jun 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/linux-year-pewdiepie/</guid>
      <description>La broma de siempre, siempre, con Linux parece hacerse realidad muy lentamente y cada año con un poco más de fuerza. Quizás sea este el año en que empiece a llegar al mainstream gracias a insospechados jugadores.</description><enclosure url="https://inobtenio.com/img/posts/linux-year-pewdiepie/pewds-linux-realm.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>No recuerdo cuándo fue la última vez que vi un video de <a href="https://www.youtube.com/@PewDiePie" target="_blank">PewDiePie</a>; es más, es posible que no haya visto más de 3 en toda la vida, pero hace unos días apareció en mi <em>feed</em> de YouTube un video suyo en el que explicaba que estaba harto de Google. No pude evitar pensar que, finalmente, Felix (como le pusieron sus padres) había perdido la batalla y que había sucumbido ante los encantos del <a href="https://en.wikipedia.org/wiki/Self-hosting_%28web_services%29" target="_blank"><em>self-hosting</em></a> dado que ya se sabía que andaba en <a href="https://www.youtube.com/watch?v=pVI_smLgTY0" target="_blank">más que coqueteos</a> con el mundo de Linux durante los últimos meses.</p>
<p>Era cierto, el sueco, intentó reemplazar cada servicio de Google que usara por uno de código abierto y gratuito. Más de una vez, la única (o la mejor) opción lo obligaban a instalar software localmente y administrarlo él mismo con todas las ventajas y desventajas que eso conlleva, convirtiéndolo a él mismo en su propio <a href="https://en.wikipedia.org/wiki/System_administrator" target="_blank"><em>sysadmin</em></a> personal.</p>
<p>No podía dejar de sonreír durante todo el video mientras trataba de adivinar con qué alternativa libre reemplazaría a cada aplicación de Google, fue divertido de una forma muy <em>nerd</em>. Pero lo que más me embargó durante la duración del video fue una sensación de <em>What the fuck?</em>, no entendía muy bien, a pesar de estarlo disfrutando, por qué un <a href="https://www.tubefilter.com/2013/08/15/its-official-pewdiepie-becomes-most-subscribed-channel-on-youtube/" target="_blank">grande</a> de internet estaba en estos asuntos.</p>
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
      <iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="allowfullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/u_Lxkt50xOg?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
    </div>

<p class="idea">El inicio del descenso de PewDiePie hacia la locura. Bienvenido al club.</p>
<p class="idea"><em>By the way</em>, la intro del video es la versión instrumental de <a href="https://www.youtube.com/watch?v=TN25ghkfgQA" target="_blank">Moon Men</a> de Jake Chudnow, popularizada por el genial Michael Stevens en el canal educativo <a href="https://www.youtube.com/@Vsauce" target="_blank">Vsauce</a></p>
<p>Es difícil saber si esto ayudará mucho o poco a que 2025 sea <a href="https://yotld.com/" target="_blank">el año de Linux en el escritorio</a>, no lo fue cuando Valve presentó la <a href="https://store.steampowered.com/steamdeck" target="_blank">Steam Deck</a> y probablemente nunca lo sea, pero es reconfortante saber que alguien con tanto alcance se ha vuelto un aliado de forma tan natural. Y cuando hablo del alcance no ignoro a canales tan grandes como <a href="https://www.youtube.com/@LinusTechTips/videos" target="_blank">Linus Tech Tips</a> que llegan a millones de personas todos los días, si no que gente como PewDiePie no sólo tiene 5 veces más personas viendo sus videos constantemente, si no que es gente que no está particularmente metida en estos temas, lo cual lo vuelve algo más raro y esperanzador, es como si Taylor Swift se volviera activista ambiental. <em>Wait</em>, no, no es <a href="https://www.cbc.ca/news/canada/british-columbia/taylor-swift-environmntal-impact-1.7380395" target="_blank">tan malo</a>.</p>
<p>Significativo y esperanzador también es el aporte de personas como <a href="https://dhh.dk/" target="_blank">DHH</a>, quien con proyectos como <a href="https://omarchy.org/" target="_blank">Omarchy</a> ha popularizado y facilitado el uso de Linux para uso cotidiano en el escritorio, lo cual, teniendo en cuenta su alcance mediático, incrementa el acceso y la adopción en forma no menor. No es el tema de hoy si Omarchy es realmente una pieza de software revolucionaria o sólo una buena colección de librerías para hacer del uso de Arch una experiencia menos peliaguda, si no cuánto suma a la noble causa.</p>
<p><em>Slowly but surely.</em></p>
<h2 id="por-qué-es-importante">¿Por qué es importante?</h2>
<p>No es mi intención disfrazarme de activista de balcón cuyo intento de carrera pseudopolítica dura lo que demora una persona en terminar de leer este artículo, pero siempre me interesó la tecnología y, más recientemente, los servidores y la administración de sistemas desde que descubrí <a href="https://www.plex.tv/" target="_blank">Plex</a> en 2014 y eso me ha traído consecuencias.</p>
<p>Por ese entonces, no tenía idea de lo que significaba auto alojar servicios localmente pero mis ganas de ver películas que no encontraba en Netflix (prácticamente el único servicio de películas on-demand que se usaba por estos lares en aquella época) hicieron que me metiera en ese <em>rabbit hole</em>. Pronto fue evidente que no podía simplemente tener Plex instalado en la única computadora de escritorio del hogar si quería ver películas en mi teléfono o en el televisor, así que necesitaba un servidor que estuviera prendido a toda hora, o lo más que se pueda, para tener un servicio mínimamente decente en una cantidad mayor de dispositivos.</p>
<p>Poco después descubrí que podía instalar <a href="https://www.home-assistant.io/" target="_blank">Home Assistant</a> en una <a href="https://www.raspberrypi.com/products/raspberry-pi-3-model-b-plus/" target="_blank">Raspberry Pi 3B+</a> que tenía sin usar por ahí y así dar mis primeros pasos en cuanto a automatización y <a href="https://es.wikipedia.org/wiki/Dom%C3%B3tica" target="_blank">domótica</a>, así conocí uno de los pasatiempos en los que más tiempo he invertido y que más satisfacciones me ha dado también.</p>
<figure class="width60perc">
  <img loading="lazy" alt="La compañera con la que muchos emprendemos este camino, la 3B+" height="481" title="La compañera con la que muchos emprendemos este camino, la 3B+" width="720"srcset="https://cdn.inobtenio.com/img/posts/linux-year-pewdiepie/rpi-3b&#43;_hu_670271bc7bb4a3e.webp 480w ,https://cdn.inobtenio.com/img/posts/linux-year-pewdiepie/rpi-3b&#43;_hu_6cc4f2d5fd31c312.webp 720w ,https://cdn.inobtenio.com/img/posts/linux-year-pewdiepie/rpi-3b&#43;_hu_1062b79af27d9d24.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/linux-year-pewdiepie/rpi-3b&#43;_hu_7eb50a62986a1e60.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/linux-year-pewdiepie/rpi-3b&#43;_hu_7eb50a62986a1e60.webp"><figcaption>La compañera con la que muchos emprendemos este camino, la 3B+</figcaption></figure><p>No es casualidad, entonces, que más de 10 años después, mi visión de ciertas cosas haya sido moldeada por este tipo de actividades. Cuestiones como la privacidad, seguridad, posesión de lo adquirido, apertura de código, derecho a reparar e intercambio de conocimiento tienen peso en mi toma de decisiones digitales, si cabe el término. Obviamente, no estoy en contra de productos bajo licencias restrictivas o la monetización del trabajo, no propongo un movimiento anárquico, una rebelión o sabotaje, no soy Tyler Durden ni <a href="https://es.wikipedia.org/wiki/Louis_Rossmann" target="_blank">Louis Rossmann</a>, no quiero que caigan Google y Microsoft, yo también uso Windows o algún producto de Apple, pero me parece que un poquito más de control de la gente sobre el software que usa y la información que expone a diario no vendría nada mal y lo que ha hecho PewDiePie, quizás sin ser demasiado consciente, es acercar —mínimamente— a mucha gente a estas ideas.</p>
<p>Es tremendamente difícil convencer a las masas de sacrificar un poco de la conveniencia que representa usar servicios totalmente controlados por grandes empresas en favor de un poco de autonomía, ¿de qué podría quejarme si yo mismo no puedo dejar de usar X, YouTube o iOS?. No es realista esperar que más de <a href="https://investors.dropbox.com/news-releases/news-release-details/dropbox-announces-fourth-quarter-and-fiscal-2024-results" target="_blank">700 millones de usuarios</a> de Dropbox compren una decena de discos duros y empiecen a usar <a href="https://www.truenas.com/" target="_blank">TrueNAS</a> de la noche a la mañana, o que tres cuartas partes del mundo cambien de Windows a alguna rara, <a href="https://hannahmontana.sourceforge.net/index.html" target="_blank">rarísima</a> distribución de Linux.<br>
¿Por qué lo harían? No quiero decirle a nadie lo que tiene que hacer. La verdad es que para la gran mayoría de nosotros no tiene mucho sentido y estoy de acuerdo. Es poco productivo cambiar lo que funciona, tan bien. Toma demasiado tiempo y muchas veces no funciona como debería, cuesta mucho, trae frustraciones y puede llegar a ser incluso más peligroso e inseguro que subir todas tus fotos a Google Photos si no se tiene cuidado. Yo ni siquiera entré por ideales de privacidad o libertad, sólo tuve curiosidad y me dejé llevar, sentimiento que espero videos como el de arriba generen en alguien en algún momento.</p>
<p>Sólo quería decir que me pone contento. Ya debería dejar de escribir tan solemnemente.</p>
<h2 id="no-tan-rápido-felix">¡No tan rápido, Felix!</h2>
<p>Todo muy bonito pero estás publicando un video sobre <em>deGoogleización</em> en YouTube. ¡En YouTube!</p>
<figure class="video width60perc">
<video preload="metadata"   autoplay="true" loop="true"
muted="true" playsinline="true">
 <source src="https://cdn.inobtenio.com/video/posts/linux-year-pewdiepie/google-youtube-subs.mp4">
</video>
<figcaption>Ajá, ajá</figcaption>
</figure>

<p>¿y cuál es el problema? Bueno, al señor YouTube no le gustan estas cosas porque podrían representar <a href="https://www.jeffgeerling.com/blog/2025/self-hosting-your-own-media-considered-harmful-updated" target="_blank"><strong>&ldquo;Contenido peligroso o dañino&rdquo;</strong></a>.</p>
<p>Hace unas semanas, el también <em>youtuber</em> <a href="https://www.youtube.com/@JeffGeerling" target="_blank">Jeff Geerling</a>, recibió más que una advertencia por parte del gigante rojito y un video suyo enseñando cómo consumir contenido (películas, series o música en este caso) de forma local, legal y sin pagar suscripciones fue removido de la plataforma porque, aparentemente, &ldquo;Contenido que describe cómo obtener acceso no autorizado o gratuito a contenido audiovisual que usualmente require un pago no está permitido en YouTube&rdquo;.</p>
<p>Geerling en ningún momento publicó un tutorial sobre cómo obtener y ver <em>Desperate Housewives</em> o el último disco de Lisa de forma gratuita; es más, nunca ha sido defensor de la piratería, pero YouTube lo eliminó automáticamente de todas formas y hasta ahora el motivo es incierto. Es posible que el algoritmo de identificación de contenido no deseado haya relacionado algunos términos usados en el video con palabras que se suelen usar en videos donde sí se promueve la piratería, pero son sólo conjeturas.<br>
El video ha sido repuesto después de una revisión manual pero el mal momento y sobretodo, la preocupación de que los términos y condiciones del sitio pueden ser aplicados de forma totalmente arbitraria, quedan.</p>
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
      <iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="allowfullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/3hFas54xFtg?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
    </div>

<p class="idea">El video de Geerling previamente eliminado por YouTube</p>
<p>Por supuesto, Felix no ha publicado ese tipo específico de contenido aún y, de llegar a darse el mismo escenario, tiene una voz lo suficientemente fuerte como para reclamar y ser oído al instante, pero quizás sería mejor que ande con un poquito de cuidado en estas nuevas aguas, sólo un poquito. Quizás a papá YouTube no le excita que se hable de estas cosas, precisamente.</p>
<h2 id="por-último-un-saludo">Por último, un saludo</h2>
<p>No sé cuál será la motivación detrás de esta especie de cambio de carrera pero me agrada. Aunque siempre supe de su existencia nunca fui un seguidor suyo pero si empieza a publicar más videos de este estilo, quizás se vuelva un creador de contenido al que deba prestarle atención.<br>
Sólo sé que el tipo dedicó gran parte de su vida a lo que le gustaba, fue, literalmente, el más exitoso en su campo, hizo quién sabe cuánto dinero y ahora, semijubilado, <a href="https://www.youtube.com/watch?v=D0-IrQY4ix8" target="_blank">se ha mudado a Japón</a> con su familia, vive tranquilo, trabaja por gusto y se divierte aprendiendo y experimentando con cosas en las que, quizás, siempre estuvo interesado.</p>
<p>Si la vida es un juego, PewDiePie terminó la misión principal en la mitad del tiempo y con todas las habilidades al tope y ahora se dedica a las misiones secundarias por diversión. Él ya ganó, qué envidia.</p>
]]></content:encoded>
    </item>
    <item>
      <title>¿De dónde saca Spotify ese color de fondo?</title>
      <link>https://inobtenio.com/posts/spotify-song-colors/</link>
      <pubDate>Sun, 01 Jun 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/spotify-song-colors/</guid>
      <description>Siempre me dio curiosidad cuando usaba Spotify cómo determinan el color del cual coloreaban el reproductor. es el color dominante? es el más llamativo? ¿qué es? Aquí un intento de averiguarlo y replicarlo.</description><enclosure url="https://inobtenio.com/img/posts/spotify-song-colors/strokes-cover.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>A lo largo de los años, he hecho varias &ldquo;cositas&rdquo; que tienen que ver, de alguna u otra forma, con Spotify. Usé la API para clasificar la música que más escucho por décadas para saber qué tan viejos eran mis gustos, para conectarla con <a href="https://www.home-assistant.io/" target="_blank">Home Assistant</a>, para separar la música por idioma y alguna otra por ahí. Pero en los últimos días estuve ocupado <a href="/posts/vobot-mini-dock-spotify/" target="_blank">haciendo que un reloj me diga qué canción estoy escuchando</a>.</p>
<p>Fue ahí donde elaboré, por primera vez, una interfaz gráfica para mi proyecto musical, dicha interfaz necesitaba de un fondo colorido para la imagen que indicaba qué canción se reproducía.<br>
El problema es que, fijándome en la documentación del portal para desarrolladores de Spotify, en ningún lado se hacía referencia a colores y mucho menos se hacía pública esa información (cosa que sí hace <a href="https://developer.apple.com/documentation/applemusicapi/artwork" target="_blank">Apple</a>, por ejemplo), tampoco en <a href="https://engineering.atspotify.com/" target="_blank">su blog de ingeniería y tecnología</a>; además, yo no tenía idea de cómo si quiera empezar a programar un algoritmo de obtención de color por lo menos similar. Tenía una idea meramente intuitiva de qué color era el que se escogía dependiendo de cómo se veía las carátulas de los álbumes, pero eso difícilmente sería suficiente.</p>
<p>Me puse a ver unos cuantos ejemplos, quizás así se me ocurriría algo, un punto por el cual empezar.</p>
<figure class="width25perc group">
  <img loading="lazy" height="1600" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_d7ab9f28e95f133c.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_d7c013a2b11b0dfc.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_2bee279007f5b060.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_91025486b4d4b108.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_91025486b4d4b108.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1561" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_9d60eaa37f59875c.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_2010da6e974fdd56.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_3d9176691a1c8344.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_dd4df8a77443ad98.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_dd4df8a77443ad98.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1600" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_858aa68c3e29fccf.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_999ebfe929c258aa.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_3810cb2cd8bfa3dd.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_16c107f1d3e0b5f2.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_16c107f1d3e0b5f2.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1561" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_d87954537417d3fc.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_df3ee13116feb1b2.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_e21b6e846921d7b3.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_6b42984eb7a87eca.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_6b42984eb7a87eca.webp"></figure><h2 id="puedo-evitar-la-fatiga">¿puedo evitar la fatiga?</h2>
<p>Pensé que las imágenes seguramente se procesan en el lado del servidor ya que los colores se mantienen (en su mayoría) a través de distintas plataformas pero también estaba seguro de no haber encontrado nada al respecto en la documentación, entonces me fui al <a href="https://open.spotify.com/" target="_blank">reproductor web</a> para escudriñar las solicitudes y respuestas y sí, efectivamente, encontré que, cuando se realiza la solicitud para obtener las letras de una canción, también incluye la respuesta el color de fondo que deberían tener, color que estaba buscando.</p>
<figure class="width85perc">
  <img loading="lazy" alt="&lsquo;Respuesta con el color de fondo&rsquo;" height="298" title="Respuesta para [Myxomatosis](https://www.youtube.com/watch?v=PxOO8YkB_Og), incluyendo color de fondo y letras (gran canción, por cierto, gracias FIFA 2004)" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_964f5741dba5c5bb.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_7ee55432644f0e9f.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_15ce71acfe76359.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_fc258a158943dcd.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_fc258a158943dcd.webp"><figcaption>Respuesta para <a href="https://www.youtube.com/watch?v=PxOO8YkB_Og" target="_blank">Myxomatosis</a>, incluyendo color de fondo y letras (gran canción, por cierto, gracias FIFA 2004)</figcaption></figure><p>La diferencia es que la dirección a la que se hace la solicitud no tiene mucho que ver con la API oficial, imagino que serán servicios internos que sólo pueden usar aplicaciones <em>first party</em>. La dirección tiene la forma <code>https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}/image/{image_url}</code> y sólo retorna el color esperado si Spotify (o <a href="https://www.musixmatch.com/" target="_blank">Musixmatch</a>) tienen registro de las letras correspondientes o si están disponibles para la región en la cual está registrada la cuenta y si la imagen a procesar también es parte de su base de datos; de lo contrario, no hay ni letra, ni color.</p>
<p>Sólo tenía que descifrar cómo obtener el token que se requiere para automatizar el proceso, simular ser un cliente web desde la terminal y obtener todos los colores que pueda querer. El impedimento fue mi flojera para encontrar patrones en los 10 encabezados que tenían las solicitudes y la amenaza de sentencia de muerte que me hizo Spotify, así que no continué por ese camino. Parecía prometedor.</p>
<figure class="width85perc">
  <img loading="lazy" alt="&lsquo;Amenaza por parte de Spotify&rsquo;" height="109" title="Amenaza por parte de Spotify" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_aa6bb36cf940886e.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_75a9d071e9869032.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_4ce31076b4a3b422.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_1b2b71e5f92e84db.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_1b2b71e5f92e84db.webp"><figcaption>Amenaza por parte de Spotify</figcaption></figure><h2 id="intentando-entender-a-spotify">Intentando entender a Spotify</h2>
<p>Tenía que idear alguna otra forma, así que intenté entender qué color escoge Spotify para los fondos. ¿quizás el más colorido? ¿quizás el que ocupa una mayor región en la imagen? ¿quizás el que más resalta? ¿a lo mejor una combinación de todos los anteriores y aún más aspectos?</p>
<p>Veamos unas cuantas imágenes acompañadas de su color de fondo seleccionado:</p>
<figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 1**: Ghost In The Machine - The Police" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/police.jpg"><figcaption><strong>Fig. 1</strong>: Ghost In The Machine - The Police</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 2**: Locura - Virus" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/virus.jpg"><figcaption><strong>Fig. 2</strong>: Locura - Virus</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 3**: The Singles 1986-1995 - Duran Duran" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/duran-duran.jpg"><figcaption><strong>Fig. 3</strong>: The Singles 1986-1995 - Duran Duran</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 4**: The Blue Room - Coldplay" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/coldplay.jpg"><figcaption><strong>Fig. 4</strong>: The Blue Room - Coldplay</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 5**: Private Eyes - Hall & Oates" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/hall&amp;oates.jpg"><figcaption><strong>Fig. 5</strong>: Private Eyes - Hall &amp; Oates</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 6**: Blur: The Best Of - Blur" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/blur.jpg"><figcaption><strong>Fig. 6</strong>: Blur: The Best Of - Blur</figcaption></figure><ul>
<li><strong>Figura 1</strong>: parece fácil, el color más vivo (o el más alejado del blanco y negro, como lo entiendo yo) es el rojo y no en una proporción despreciable. Color resultante: rojo.</li>
<li><strong>Figura 2</strong>: supongamos que ni el blanco ni el gris de las letras superiores serán elegidos, así que quedan como opciones el magenta, amarillo, celeste y hasta verde pero ninguno sobresale demasiado. Color resultante: rosa.</li>
<li><strong>Figura 3</strong>: yo esperaba que fuera negro pero, aparentemente, el algoritmo lo evita a toda costa, al igual que el blanco. Color resultante: gris.</li>
<li><strong>Figura 4</strong>: aquí hay problemas, descarto el negro pero el rojo/naranja resalta del azul, parece que el azul no es tan discriminado pero me genera dudas. Color resultante: azul.</li>
<li><strong>Figura 5</strong>: bueno, se va comprobando la hipótesis del color más colorido, aunque el amarillo pudo haber sido descartado por apagado o por poco representativo. Color resultante: magenta.</li>
<li><strong>Figura 6</strong>: se pone difícil, hay por lo menos tres colores que se podrían considerar vivos de alguna forma pero aparentemente, de entre ellos, se elige al más oscuro. Color resultante: azul.</li>
</ul>
<p>Si tomamos en cuenta también las primeras imágenes de este artículo, la idea se suele mantener en pie salvo por dos. En el caso de la segunda, yo diría que el color predominante, en todo aspecto, debería oscilar entre el naranja y el rojo pero el fondo elegido oficialmente es azul; un poco más de lo mismo con la tercera, el color que resalta es una suerte de naranja pero resulta en azul también.</p>
<p>No he podido hasta ahora determinarlo del todo, pero creo que la cosa va más o menos así:</p>
<ul>
<li>La imagen se repartirá en <code>N</code> grupos de colores similares para no lidiar con potencialmente cientos de colores ligeramente diferentes. Esto ayuda al rendimiento del algoritmo.</li>
<li>Siempre se va a preferir a los colores más coloridos o vívidos (definidos más adelante) por encima del resto. Este factor de preferencia será definido por <code>qC</code>.</li>
<li>En igualdad de condiciones en cuanto a qué tan colorido es un color, el color más oscuro (o sea, el menos luminoso) gana. Este coeficiente de preferencia será almacenado en <code>qD</code>.</li>
<li>El porcentaje de la imagen que ocupa el color se tendrá en cuenta pero no se le dará tanta relevancia como su nivel de <em>vividness</em>, permítanme el neologismo coyuntural.Dicha relevancia será definida por <code>qR</code>.</li>
<li>Si el color resultante es blanco o negro se elegirá, en su lugar, un gris más claro o más oscuro dependiendo del color obtenido previamente.</li>
<li>Al color obtenido finalmente se le incrementará la saturación en un factor de <code>S</code> para hacerlo contrastar más con el color blanco.</li>
</ul>
<p>En pseudo código, ignoren la caligrafía:</p>
<figure class="width85perc no-conversion no-responsive no-zoom">
  <img loading="lazy" height="931" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/pseudo-code.jpg"></figure><h2 id="intentando-copiar-a-spotify">Intentando copiar a Spotify</h2>
<p>Ahora, ¿cómo calcular la  (permítanme la palabreja) &ldquo;vividez&rdquo;? Con coordenadas RGB es un poco complicado, así que trasladaremos los colores al espacio de color <a href="https://en.wikipedia.org/wiki/CIELAB_color_space" target="_blank">CIELAB</a>, que nos permite realizar este tipo de operaciones matemáticas de forma más simple y sin tener que recurrir a trigonometría necesariamente. Es como pasar de coordenadas cartesianas a polares. En <a href="https://www.xrite.com/-/media/xrite/files/whitepaper_pdfs/l10-001_a_guide_to_understanding_color_communication/l10-001_understanding_color_en.pdf" target="_blank">este enlace</a> se puede encontrar una guía de X-Rite y PANTONE para entender mejor cómo se expresan matemáticamente los colores en sus distintos sistemas.</p>
<p>Según la teoría, qué tan vívido percibe un humano un determinado color depende de su saturación y su luminosidad, de la siguiente forma:</p>
<p class="latex-big">\begin{aligned}
V = (L^*)^2 + (C^*ab)^2
\end{aligned}</p>
<p class="idea">Donde L* es luminosidad y C*ab es saturación</p>
<p>Pero la ignoré tras descubrir que centrarme en la saturación (<code>C*ab</code>) como primer componente me daba mejores resultados.</p>
<p>Sin embargo, para el cálculo de la luminosidad sí haremos uso del espacio RGB, ya que es más sencillo de esa forma. La ecuación (descrita en <a href="https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf" target="_blank">este manual</a> del organismo de telecomunicaciones de la ONU en el apartado 3.2) es:</p>
<p>\begin{aligned}
Y = 0.2126 * R_{lin} + 0.7152 * G_{lin} + 0.0722 * B_{lin}
\end{aligned}</p>
<p class="idea">Donde cada componente RGB está expresado de forma lineal ―o sin la codificación gama―</p>
<p>Utilicé los valores de <strong>10</strong> para el número de grupos o <em>clusters</em> y de <strong>5.0</strong>, <strong>1.5</strong> y <strong>0.8</strong> para <code>qC</code>, <code>qD</code> y <code>qR</code>, respectivamente, después de haber hecho unas pruebas rápidas viendo qué color devolvía el algoritmo y qué color ponía Spotify en sus aplicaciones. Funcionaba bien empíricamente pero quizás había coeficientes que funcionaban mejor y que me tomarían mucho tiempo de ensayo y error encontrar. Aparte de eso, la fórmula que se me había ocurrido podría o no ser una buena alternativa para el cálculo que necesitaba.<br>
Era momento de usar la estadística para respaldar mis suposiciones.</p>
<h2 id="intentando-mejorar-la-copia">Intentando mejorar la copia</h2>
<p>Con ayuda de la dirección prohibida de la cual Spotify no quería que hiciera uso que mencioné líneas más arriba (de la que hice uso con una cuenta alternativa para no morir) armé un <em>script</em> rápido para obtener datos clave de unas 100 canciones medianamente aleatorias (aunque seguramente influenciadas por mis gustos musicales), lo cual me permitía someter las carátulas de los álbumes a ser procesadas por el algoritmo y comparar el resultado con el color oficial, aparte de los tres aspectos que contribuyen al puntaje final sin tomar en cuenta los coeficientes <code>qX</code>.</p>
<p>Algo más o menos así, aunque con bastantes más filas, con 10/20 resultados por <code>id</code> (dependiendo de <code>N</code>) y más decimales en los números:</p>
<table>
  <thead>
      <tr>
          <th>track_id</th>
          <th>track_name</th>
          <th>cover_url</th>
          <th>cluster_color⠀⠀⠀</th>
          <th>spotify_color⠀⠀⠀</th>
          <th>chroma</th>
          <th>darkness</th>
          <th>dominance</th>
          <th>score</th>
          <th>color_delta</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>id1</td>
          <td>name1</td>
          <td>url1</td>
          <td>(193, 53, 115)</td>
          <td>(213, 54, 125)</td>
          <td>0.5519</td>
          <td>0.8480</td>
          <td>0.0694</td>
          <td>4.0873</td>
          <td>5.1784</td>
      </tr>
      <tr>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
      </tr>
      <tr>
          <td>id1</td>
          <td>name1</td>
          <td>url1</td>
          <td>(253, 251, 251)</td>
          <td>(213, 54, 125)</td>
          <td>0.0073</td>
          <td>0.0282</td>
          <td>0.7031</td>
          <td>0.6418</td>
          <td>57.4733</td>
      </tr>
      <tr>
          <td>id2</td>
          <td>name2</td>
          <td>url2</td>
          <td>(181, 78, 37)</td>
          <td>(219, 59, 31)</td>
          <td>0.5463</td>
          <td>0.8446</td>
          <td>0.0699</td>
          <td>4.0545</td>
          <td>13.1916</td>
      </tr>
      <tr>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
          <td>︙</td>
      </tr>
      <tr>
          <td>id2</td>
          <td>name2</td>
          <td>url2</td>
          <td>(247, 248, 246)</td>
          <td>(219, 59, 31)</td>
          <td>0.0057</td>
          <td>0.0612</td>
          <td>0.0534</td>
          <td>0.1633</td>
          <td>66.4606</td>
      </tr>
  </tbody>
</table>
<p class="idea"><code>chroma</code> hace referencia a <code>colorfulness</code> en pseudocódigo. Parecía más apropiado.</p>
<!--
|id3   |track_name3   |url3        |(105, 138, 70)    |(99, 127, 70)     |0.2382   |0.7785   |0.0426   |2.3930   |5.35786     |
|id3   |track_name3   |url3        |(228, 211, 191)   |(99, 127, 70)     |0.1315   |0.3174   |0.2627   |1.3440   |72.4671     |
|id4   |track_name4   |url4        |(186, 13, 25)     |(234, 25, 25)     |0.7381   |0.8919   |0.1062   |5.1136   |13.2909     |
|id4   |track_name4   |url4        |(239, 243, 244)   |(234, 25, 25)     |0.0197   |0.1027   |0.0758   |0.3137   |69.1145     |
|id5   |track_name5   |url5        |(167, 16, 14)     |(231, 35, 35)     |0.6637   |0.9138   |0.1279   |4.7920   |18.6314     |
|id5   |track_name5   |url5        |(234, 219, 215)   |(231, 35, 35)     |0.0728   |0.2599   |0.0015   |0.7556   |49.4190     |
-->
<p>En <a href="https://www.r-project.org/" target="_blank">R</a> pude realizar un par de regresiones lineales para encontrar los mejores coeficientes, por lo menos para las 100 canciones en mención, dando más peso a los casos en los que un <code>score</code> mayor coincidía con un <code>color_delta</code> menor, lo cual significa que la fórmula funciona (o que está en el buen camino) y quitando relevancia al resto de resultados por cada grupo de colores pertenecientes a una imagen.</p>
<p>Pero la fórmula podía no ser la más adecuada para el cálculo, así que también intenté un ligero cambio:</p>
<pre tabindex="0"><code>  S = qC * chroma + qD * darkness + qR * dominance =&gt;  S = chroma ^ qC + darkness ^ qD + dominance ^ qR
</code></pre><p class="idea">Nótese el cambio de <code>*</code> a <code>^</code>, pasando de una fórmula lineal a una exponencial.</p>
<p>Y volví a generar datos. El formato era el mismo que el de la tabla de más arriba pero los datos, claro, cambiaron. Decidí no dar demasiadas vueltas y empezar con valores bastantes parecidos a los que la fórmula lineal, <strong>5.0</strong>, <strong>1.2</strong> y <strong>0.9</strong>, y confiar en las matemáticas.</p>
<p>De esta forma, sin embargo, una regresión lineal ya no sería el análisis adecuado para los datos, así que después de algo de <em>research</em> fui por el método de mínimos cuadrados no lineales (o <a href="https://en.wikipedia.org/wiki/Non-linear_least_squares" target="_blank">NLS</a> para los amigos).</p>
<p>Luego de unas cuantas rondas de <a href="https://en.wikipedia.org/wiki/Curve_fitting" target="_blank"><em>fitting</em></a> puse los resultados en una pequeña tabla para poder escoger mejor el camino que tomaría.</p>
<figure class="width85perc no-conversion no-responsive">
  <img loading="lazy" height="110" title="Resultados de los análisis estadísticos, resaltando los mejores por categoría. Las anotaciones \"(fit)\" hacen referencia a la data después de haber sido ajustada." width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/table_1.png"><figcaption>Resultados de los análisis estadísticos, resaltando los mejores por categoría. Las anotaciones &ldquo;(fit)&rdquo; hacen referencia a la data después de haber sido ajustada.</figcaption></figure><p>Es claro que ninguna de las dos fórmulas, ya sea usando 10 o 20 grupos de colores, es demasiado acertada. Lo más seguro, creo, es que los ingenieros de Spotify tomen en cuenta más variables de las que yo con este algoritmo, aunque no se me ocurre cuáles.
También es posible que la fórmula en sí sea bastante más complicada, por supuesto.</p>
<p>Pero no importa, en todo ese mar de números mediocres hay un método que seguramente lo es menos que el resto, así que iré a la guerra con él aunque sólo tenga un porcentaje de aciertos del 31%.</p>
<p>El código resultante para la fórmula lineal ajustada de 20 <em>clusters</em> terminaría siendo este:</p>
<div class="tab-container" id="2dd38712">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="python">python</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-python" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> math
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> numpy <span style="color:#fc5fa3">as</span> np
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">from</span> PIL <span style="color:#fc5fa3">import</span> Image
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">from</span> sklearn.cluster <span style="color:#fc5fa3">import</span> KMeans
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>qC = <span style="color:#d0bf69">4.9226</span>
</span></span><span style="display:flex;"><span>qD = <span style="color:#d0bf69">1.4060</span>
</span></span><span style="display:flex;"><span>qR = <span style="color:#d0bf69">0.7932</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">calculate_dark_colorfulness</span>(rgb):
</span></span><span style="display:flex;"><span>    red, green, blue = [x / <span style="color:#d0bf69">255.0</span> <span style="color:#fc5fa3">for</span> x in rgb]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    red_greenness = red - green <span style="color:#6c7986"># or the a component</span>
</span></span><span style="display:flex;"><span>    yellow_blueness = (red + green)/<span style="color:#d0bf69">2</span> - blue <span style="color:#6c7986"># or the b component. red + green output yellow in additive color (light)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    chroma = math.sqrt(red_greenness ** <span style="color:#d0bf69">2</span> + yellow_blueness ** <span style="color:#d0bf69">2</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Choose any of these three, bascially according to preference, the output is not going to change
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    significantly. If you care about accuracy, you&#39;re free to do some research to determine the best
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    option for your use case. Keep in mind that the overall formula coefficients might need to change too.
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    They&#39;re as follows:
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    First option: the standard way of obtaining luminance from RGB coordinates but without linearizing
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Second option: same as the previous, but linear (removing gamma correction)
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Third option: Independent HSP color space &#34;percieved brightness&#34; (https://alienryderflex.com/hsp.html)
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">#    luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue # Relative luminance</span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">#    luminance = 0.2126 * (red ** (1/2.2)) + 0.7152 * (green ** (1/2.2)) + 0.0722 * (blue ** (1/2.2))# Relative luminance without gamma correction</span>
</span></span><span style="display:flex;"><span>    luminance = math.sqrt(<span style="color:#d0bf69">0.299</span> * (red ** <span style="color:#d0bf69">2</span>) + <span style="color:#d0bf69">0.587</span> * (green ** <span style="color:#d0bf69">2</span>) + <span style="color:#d0bf69">0.114</span> * (blue ** <span style="color:#d0bf69">2</span>)) <span style="color:#6c7986"># Percieved brightness (HSP)</span>
</span></span><span style="display:flex;"><span>    darkness = <span style="color:#d0bf69">1</span> - luminance
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> (chroma, darkness)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">### TO-DO: Implement improving picked color contrast with white, as lyrics are always white, to better match Spotify&#39;s choice</span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">improve_white_contrast</span>(rgb, brightness_factor=<span style="color:#d0bf69">0.85</span>, saturation_boost=<span style="color:#d0bf69">1.1</span>):
</span></span><span style="display:flex;"><span>    red, green, blue = [x / <span style="color:#d0bf69">255</span> <span style="color:#fc5fa3">for</span> x in rgb]
</span></span><span style="display:flex;"><span>    hue, sat, value = colorsys.rgb_to_hsv(red, green, blue)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    sat = <span style="color:#d0a8ff">min</span>(sat * saturation_boost, <span style="color:#d0bf69">1.0</span>)
</span></span><span style="display:flex;"><span>    value = <span style="color:#d0a8ff">max</span>(value * brightness_factor, <span style="color:#d0bf69">0.0</span>)  <span style="color:#6c7986"># darken</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    red, green, blue = colorsys.hsv_to_rgb(hue, sat, value)
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> <span style="color:#d0a8ff">tuple</span>(<span style="color:#d0a8ff">int</span>(x * <span style="color:#d0bf69">255</span>) <span style="color:#fc5fa3">for</span> x in (red, green, blue))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">extract_color_clusters</span>(num_clusters):
</span></span><span style="display:flex;"><span>    image = Image.open(<span style="color:#fc6a5d">&#34;./cover.jpg&#34;</span>).convert(<span style="color:#fc6a5d">&#34;RGB&#34;</span>)
</span></span><span style="display:flex;"><span>    width, height = image.size
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">try</span>:
</span></span><span style="display:flex;"><span>      pixels = np.array(image).reshape(-<span style="color:#d0bf69">1</span>, <span style="color:#d0bf69">3</span>) <span style="color:#6c7986"># Turn a RGB matrix into an RGB 2D array</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#6c7986"># Cluster colors using K-Means</span>
</span></span><span style="display:flex;"><span>      kmeans = KMeans(n_clusters=num_clusters, random_state=<span style="color:#d0bf69">0</span>, n_init=<span style="color:#fc6a5d">&#34;auto&#34;</span>)
</span></span><span style="display:flex;"><span>      kmeans.fit(pixels)
</span></span><span style="display:flex;"><span>      labels = kmeans.labels_
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      colors = []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#6c7986"># Group colors by cluster and calculate average for each</span>
</span></span><span style="display:flex;"><span>      clusters = [[] <span style="color:#fc5fa3">for</span> _ in <span style="color:#d0a8ff">range</span>(num_clusters)]
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">for</span> i, label in <span style="color:#d0a8ff">enumerate</span>(labels):
</span></span><span style="display:flex;"><span>        clusters[label].append(pixels[i])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">for</span> group in clusters:
</span></span><span style="display:flex;"><span>        color = np.mean(group, axis=<span style="color:#d0bf69">0</span>)
</span></span><span style="display:flex;"><span>        chroma, darkness = calculate_dark_colorfulness(color)
</span></span><span style="display:flex;"><span>        dominance = <span style="color:#d0a8ff">len</span>(group)/(width * height)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        score = chroma * qC + darkness * qD + dominance * qR
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        colors.append({
</span></span><span style="display:flex;"><span>          <span style="color:#fc6a5d">&#39;color&#39;</span>: <span style="color:#d0a8ff">tuple</span>(<span style="color:#d0a8ff">int</span>(c) <span style="color:#fc5fa3">for</span> c in color),
</span></span><span style="display:flex;"><span>          <span style="color:#fc6a5d">&#39;score&#39;</span>: score,
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">return</span> colors
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">except</span> Exception <span style="color:#fc5fa3">as</span> e:
</span></span><span style="display:flex;"><span>      <span style="color:#d0a8ff">print</span>(e)
</span></span><span style="display:flex;"><span>      <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Black and white image?&#34;</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">return</span> [{
</span></span><span style="display:flex;"><span>        <span style="color:#fc6a5d">&#39;color&#39;</span>: (<span style="color:#d0bf69">128</span>,<span style="color:#d0bf69">128</span>,<span style="color:#d0bf69">128</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#fc6a5d">&#39;score&#39;</span>: <span style="color:#d0bf69">10.0</span>,
</span></span><span style="display:flex;"><span>      }]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">get_best_color</span>():
</span></span><span style="display:flex;"><span>  <span style="color:#fc5fa3">return</span> <span style="color:#d0a8ff">max</span>(extract_color_clusters(<span style="color:#d0bf69">20</span>), key=<span style="color:#fc5fa3">lambda</span> c: c[<span style="color:#fc6a5d">&#39;score&#39;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">if</span> __name__ == <span style="color:#fc6a5d">&#39;__main__&#39;</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">print</span>(get_best_color())
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">Ver más</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("2dd38712");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "Ver más";
  } else {
    container.classList.add("expanded");
    btn.textContent = "Ver menos";
  }
}
</script>

</br>
<p>Código también disponible en este <a href="https://gist.github.com/Inobtenio/bd6c1c20e1af3dbafeafb4dc781d0144" target="_blank"><em>gist</em></a> en GitHub.</p>
<p>Por supuesto, la fórmula final pasa a ser:</p>
<p>\begin{align}
S = 4.923 * Ch + 1.406 * Dk + 0.793 * Do
\end{align}</p>
]]></content:encoded>
    </item>
    <item>
      <title>Spotify en el Vobot Mini Dock</title>
      <link>https://inobtenio.com/posts/vobot-mini-dock-spotify/</link>
      <pubDate>Sat, 24 May 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/vobot-mini-dock-spotify/</guid>
      <description>El Vobot Mini Dock es un reloj que funciona como hub USB para el que se pueden desarrollar aplicaciones, así que como buen curioso intento hacer que me diga qué canción estoy escuchando en todo momento.</description><enclosure url="https://inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-pressure.jpg" length="0" type="image/jpeg" />
      <content:encoded><![CDATA[<p>De vez en cuando, cuando estoy en frente de la computadora en el escritorio, necesito saber cuál es la hora. Por supuesto, siempre puedo mirar a las esquinas inferiores de los monitores (Windows 11) para obtener la información, pero no con poca frecuencia tengo demasiadas cosas encima de la mesa que me dificultan la visibilidad de la zona más inferior de las pantallas. Aparte, mientras estoy jugando algo, generalmente a pantalla completa, el reloj del monitor principal desaparece y debo girar la cabeza para verificar la hora. Es el problema más intrascendente de la historia pero sentía que necesitaba un reloj dedicado para resolverlo.</p>
<p>Es una idea que lleva en mi mente unos meses, quizás más de un año. Como siempre, me demoro en resolver las cosas que me causan molestias si no es urgente, así que lo más fácil era esperar a que la solución, o algo que se le parezca, llegue a mí.</p>
<p>Y sucedió. No recuerdo cómo, no recuerdo cuándo, no recuerdo dónde pero me enteré de la existencia del <a href="https://getvobot.com/mini-dock" target="_blank">Vobot Mini Dock</a>, que es una especie de reloj de escritorio conectado a internet que funciona como hub USB y con algunas aplicaciones que podrían servirle a alguien. Lo que más me llamó la atención fue que el producto permitía aplicaciones desarrolladas por terceros y hasta tenían una pequeña tienda para encontrar nuevas. Lo compré.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Puede emular algunos juegos también (imagen oficial de Vobot)" height="480" title="Puede emular algunos juegos también (imagen oficial de Vobot)" width="720"srcset="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-tetris_hu_ee1a39b792268eb0.webp 480w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-tetris_hu_b2d79ed1f89a4fbf.webp 720w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-tetris_hu_b24c5f4dcfa6e89e.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-tetris_hu_dc4b639247f3633.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-tetris_hu_dc4b639247f3633.webp"><figcaption>Puede emular algunos juegos también (imagen oficial de Vobot)</figcaption></figure><p>Interesante dispositivo.</p>
<p>Al conectarlo y probarlo por primera vez resultó aparente el uso principal que le quería dar. Necesitaba de él un servicio similar al que ofrecía el <a href="https://web.archive.org/web/20210413130229/http://carthing.spotify.com/" target="_blank">Spotify Car Thing</a> (captura de Archive.org ya que la página original ya no existe) en sus buenos días <a href="https://support.spotify.com/us/article/car-thing-discontinued/" target="_blank">antes de morir</a>.</p>
<p>Obviamente, no podía pedirle demasiado. Está basado en el <a href="https://www.espressif.com/en/products/socs/esp32-s3" target="_blank">ESP32S3</a>, que es un microcontrolador con 512 KB de RAM y un CPU a 240 MHz (para referencia, un Samsung Galaxy S25 tiene 12 GB de RAM y un procesador con núcleos que pueden alcanzar los 4.47 GHz) y tiene una pantalla LCD de 240x320 píxeles; o sea, bastante básico pero seguramente suficiente.
Sólo necesitaba que me dijera qué canción se estaba reproduciendo en ese momento, el artista y, con un poco de suerte, que me muestre la portada del álbum al cual pertenece la canción en cuestión.</p>
<p>Busqué en la tienda de aplicaciones alguna app que ofreciera algo parecido pero no tuve suerte, la oferta es más bien pobre y sólo hay una aplicación de un tercero aparte de las aproximadamente 10 que ofrece la propia Vobot.</p>
<p>Era momento de llevar mi visión a la realidad yo mismo.</p>
<figure>
  <video class="width50perc" src="/video/posts/vobot-mini-dock-spotify/thanos-myself.mp4" autoplay loop muted playsinline></video>
</figure>
<h2 id="el-problema">El problema</h2>
<p>No tenía idea de qué lenguaje usaba Vobot para las aplicaciones del Mini Dock, pero seguramente C++ o alguna variante. Pues no, usa <a href="https://micropython.org/" target="_blank">MicroPython</a> para la lógica y <a href="https://lvgl.io/" target="_blank">LVGL</a> para los gráficos. Experiencia con Python tengo, así que programar en su versión para microcontroladores debería ser similar, pero jamás había siquiera oído de LVGL; obviamente, tenía que leer toda la documentación que pudiera para aprender a usarlo.</p>
<p>La documentación de LVGl para C es bastante completa pero para MicroPython es, digamos, un poco optimista por no decir incompleta. No pude aprender demasiado leyendo la referencia de la API; además, los nombres de métodos son bastante parecidos entre uno y otro lenguaje pero el patrón no es totalmente evidente a primera vista. Menos mal tuve ayuda de un par de proyectos preexistentes precisamente para este relojito.
Gracias a @itandy848 y su repo <a href="https://github.com/itandy848/vobot/tree/main/HK%20Weather" target="_blank">HK Weather</a> y a la misma <a href="https://github.com/myvobot/dock-mini-apps" target="_blank">Vobot</a> pude reconstruir las partes del código con las que la documentación oficial no me ayudó mucho.</p>
<p>Ah, por cierto, la documentación de Vobot para el desarrollo de aplicaciones es prácticamente inexistente. De no haber hecho público el código para algunas de sus aplicaciones pre-instaladas no estaría escribiendo este artículo o habría sido publicado 9 meses más tarde como mínimo.</p>
<p>Bien, con todas (o casi todas) las piezas en su lugar, finalmente podía dedicarme a escribir el código.</p>
<p>La idea es simple:</p>
<ul>
<li>Realizar el proceso de autenticación para que Spotify supiera quién está consumiendo su API</li>
<li>Preguntar por la canción siendo reproducida con toda su información</li>
<li>Mostrar la info relevante en pantalla</li>
<li>Repetir cada N segundos para mantener la aplicación autorizada</li>
<li>Volver a la autenticación cada vez que mi token de identificación expire</li>
</ul>
<p>Las cosas salieron más o menos como se esperaba, me tomó mucho tiempo aprender a utilizar bien LVGL, no sabía cómo mostrar imágenes en pantalla, era importante si las imágenes eran PNG o JPEG (Spotify las devuelve en formato JPEG), LVGL parece sólo funcionar con imágenes en formato binario y algunos detalles más. Pero tuve más problemas con dos aspectos importantes de la aplicación: las imágenes y las constantes solicitudes a los servidores de Spotify, así como el tamaño de su respuesta.</p>
<h3 id="las-imágenes">Las imágenes</h3>
<figure class="width85perc">
  <img loading="lazy" alt="Primera versión con la carátula recortada" height="288" title="Primera versión con la carátula recortada" width="720"srcset="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_2844a5050fd5250a.webp 480w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_258e39cbff6f2717.webp 720w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a7689f86adc4eb5a.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a6505054c26150e3.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a6505054c26150e3.webp"><figcaption>Primera versión con la carátula recortada</figcaption></figure><p>Como se ve más arriba, la primera versión (y lo más complicado) fue mostrar las imágenes en pantalla. Cuando, después de un buen rato, lo logré (había que especificar el peso de la imagen al momento de crear el objeto de LGVL que se encagaría del resto) me di cuenta de que las imágenes no cabían del todo bien en la pantalla. Además, aún faltaba agregar el texto y todo eso.<br>
Resulta que en LGVL, sobretodo cuando se trata de microcontroladores como este ESP32, llevar a cabo el redimensionado de una imagen es un proceso costoso en términos de cómputo y no quería darle demasiado trabajo al pobre.</p>
<p>¿quizás si le pido a Spotify una imagen más pequeña? Bueno, Spotify mantiene 3 tamaños de imagen para las carátulas de los álbumes, pero ninguna de unos 150x150, que era lo que necesitaba. Las hay de 64x64, 300x300 y 640x640.</p>
<h3 id="las-solicitudes">Las solicitudes</h3>
<p>Cada cierto tiempo, digamos 3 segundos, tenía que hacer una solicitud para mantener el reloj relativamente sincronizado con el backend de Spotify, el problema es que cada una de las solicitudes que hacía demoraba unos milisegundos más de lo que me hubiera gustado y el tamaño de las respuestas era un poco atroz, estaban en el orden de decenas (o hasta centenares) de kilobytes. No pintaba bien para el pequeño ESP32.</p>
<p>Aparte de esto, se me ocurrió que quedaría bien pintar el fondo del color predominante tal cual como lo hace Spotify en sus aplicaciones. Siempre me gustó cómo se veía y también me causaba intriga cómo escogían el color.<br>
Pintar la pantalla entera también tiene un costo no menor.</p>
<p>Con todo esto, resultaba medianamente evidente que seguir poniendo toda la lógica en el dispositivo iba a dejarme con una experiencia de uso mediocre, la lentitud se notaba, la escasa memoria se llenaba rápido y los 3 segundos de retraso que debían existir como máximo se convertían en 5 o 10 debido a todo lo que tenía que hacer el reloj antes de decirme qué era lo nuevo que se estaba escuchando. Sumado a la pobre optimización de mi código, por supuesto.</p>
<h2 id="cliente-y-servidor">Cliente y servidor</h2>
<p>No esperé a que fuera demasiado evidente y me puse a resolver los problemas mencionados en el Python de toda la vida en una máquina virtual en un servidor x86. En ese entorno la manipulación de imágenes y las solicitudes constantes no representarían ningún tipo de problema, aparte de que trabajar en Python es más fácil debido a la falta de limitaciones y que no todo el código tiene que ser un poquito especial o asíncrono.</p>
<p>Las ventajas eran evidentes, le entregaría al reloj una imagen exactamente del tamaño que necesita y con calidad rebajada para reducir el tamaño; además, al controlar yo la información que llegaba a él, podía quitar de las respuestas de Spotify todo lo que no fuera vital para el funcionamiento de la aplicación y así reducir también el tamaño del JSON que se transmitía, aparte de responder considerablemente más rápido por tratarse de un servidor &ldquo;local&rdquo;.</p>
<figure>
  <video class="width85perc" src="/video/posts/vobot-mini-dock-spotify/final.mp4" autoplay loop muted playsinline></video>
  <figcaption>En persona los números no titilan</figcaption>
</figure>
<p>Finalmente pude terminar el proyecto. El ícono de estado de reproducción indica si se está reproduciendo o no algo (indica estado porque no es un botón, aunque estoy pensando en cambiar eso, así que pasaría a indicar acción), la barra de progreso se mueve con el tiempo, las etiquetas del nombre del artista y canción se mueven con efecto de marquesina si el texto es demasiado largo para el ancho de la pantalla y, lo más importante, logré pintar el fondo de la pantalla del color dominante o resaltante de la imagen.</p>
<p>¿cómo logré esto último? Bueno, ya escribí al respecto en <a href="/posts/spotify-song-colors" target="_blank">otro post</a>.</p>
<p>El código fuente tanto de la aplicación en el reloj como del servidor intermediario entre Spotify y el Mini Dock se encuentran en <a href="https://gist.github.com/Inobtenio/9e1840cbe3eb82a02c7e7c0ff9d57867" target="_blank">este <em>gist</em></a> en Github.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Haciendo cosplay de compositor para encontrar una canción</title>
      <link>https://inobtenio.com/posts/finding-music-copying/</link>
      <pubDate>Wed, 23 Apr 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/finding-music-copying/</guid>
      <description>Después de días de agonía por no poder encontrar una canción aún usando todos los medios que conozco para identificar música volteé a mirar a GarageBand a ver si me daba una mano con la tarea.</description><enclosure url="https://inobtenio.com/img/posts/finding-music-copying/gazebo-cover-noname.jpg" length="0" type="image/jpeg" />
      <content:encoded><![CDATA[<p>Este va a ser rápido.</p>
<p>Hace un par de días llegó a mi mente la melodía de una canción que había escuchado hace no demasiado tiempo por última vez, quizás unos meses o un año. No recordaba nada salvo eso y que en algún punto el cantante (o cantantes) mencionaban la palabra &ldquo;dangerous&rdquo;.<br>
Hice lo que cualquier mortal haría, preparé mi garganta para imitar un piano y un sintetizador de la mejor forma posible para que <a href="https://www.shazam.com/" target="_blank">Shazam</a> pudiera ayudarme. Me esmeré bastante pero no pude lograr que la reconociera, seguramente porque mi habilidad de imitar instrumentos es deprimente.</p>
<p>Para otorgar un poco de contexto y risas, este soy yo intentando sonar como la canción en cuestión:</p>

    <figure class="audio">
      <audio controls id="audio-001" preload="auto">
        <source src="https://cdn.inobtenio.com/audio/posts/finding-music-copying/trying.m4a" type="audio/mp4">
      </audio>
        <figcaption>Intento fallido</figcaption>
    </figure>

<p>Al no obtener una respuesta satisfactoria por parte de Shazam, decidí probar con ChatGPT. Estos son todos los datos que, luego de varias preguntas, logramos recopilar con lo poco que recordaba:</p>
<ul>
<li>Voces masculinas de ensueño</li>
<li>Ambiente romántico y/o retro-futurista</li>
<li>Una base melódica de sintetizador</li>
<li>Piano de notas simples</li>
<li>Ritmo de medio tiempo (~100 BPM)</li>
<li>Sensación de electrónica/synth-pop</li>
<li>Un ritmo suave con percusión mínima</li>
<li>Atmósfera cósmica y ligeramente melancólica</li>
</ul>
<p>Pero nada. Algunas recomendaciones eran bastante interesantes y hasta similares en melodía o tempo pero ninguna era la que buscaba. Comenté el problema con un par de personas con esperanzas de obtener un resultado satisfactorio pero si bien reconocieron el sonido que les proporcioné, no sabían decirme a qué canción pertenecía. Mala suerte.</p>
<p>Yo recordaba que la canción era algo simple en su esquema, me pareció que la recordaba relativamente bien, así que se me ocurrió armar una maqueta en <a href="https://www.apple.com/mac/garageband/" target="_blank">GarageBand</a>, hacerla sonar tan parecido a la original como pudiera y luego volver a recurrir a Shazam, así que me puse a jugar con la aplicación, era todo un primerizo.</p>
<p>El proyecto se terminó viendo así:</p>
<figure class="width85perc">
  <img loading="lazy" alt="Proyecto en GarageBand" height="415" title="Proyecto en GarageBand" width="720"srcset="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_75910c1ae7beef7d.webp 480w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_d526b96b5de86ca2.webp 720w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_6423994ed4674c9b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_e85fd0323737cc7f.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_e85fd0323737cc7f.webp"><figcaption>Proyecto en GarageBand</figcaption></figure><p>Sabía (o esperaba) que unos cuantos segundos de pista serían suficientes para que pueda ser reconocida. El proyecto sólo contempla un piano, un sintetizador y una base de batería electrónica, así que es bastante simple. No estaba muy seguro de si la batería sonaba en conjunto con el piano, por eso es que hay una sección con y otra sin ella.</p>
<p>Y terminó sonando así:</p>

    <figure class="audio">
      <audio controls id="audio-002" preload="auto">
        <source src="https://cdn.inobtenio.com/audio/posts/finding-music-copying/track.m4a" type="audio/mp4">
      </audio>
        <figcaption>Un poco mejor</figcaption>
    </figure>

<p>Un segundo intento con Shazam y <em>voilà</em>, encontré lo que tanto buscaba.
La canción en cuestión es <em>I Like Chopin</em> de Gazebo y no era synthpop, no tenía dos voces masculinas y, sobretodo, no tenía en ninguna parte de sus letras la palabra &ldquo;dangerous&rdquo; (era &ldquo;rainy days&rdquo;, cómo falló mi memoria). Con razón ChatGPT no pudo ayudarme. &#x1f614;</p>
<figure class="width50perc">
  <img loading="lazy" alt="EP de I Like Chopin" height="720" title="EP de I Like Chopin" width="720"srcset="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_a373c1f5ea73d041.webp 480w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_2a6912b001c1630c.webp 720w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_c8dbb7b16dd5cbc9.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_5e830e4e5ef734a5.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_5e830e4e5ef734a5.webp"><figcaption>EP de I Like Chopin</figcaption></figure><p>Aquí una muestra de la canción:</p>
<iframe class="spotify-embed-compact" src="https://open.spotify.com/embed/track/2FHnN5ELL83TGbtXMDzoiJ?utm_source=generator" width="100%" height="152" frameBorder="0" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
<h3 id="para-terminar-sólo-por-si-acaso">Para terminar, sólo por si acaso</h3>
<p>Quería ver si realmente fue mi responsabilidad no haber encontrado la canción con la ayuda de ChatGPT, así que armé una nueva sesión en donde le di todos los datos corregidos poco a poco. Hasta le dije que se centrara en buscar canciones que incluyeran las palabras &ldquo;rainy days&rdquo;, que en realidad son parte de la canción, y ni así me dio una respuesta satisfactoria. Es más, parece que no puede &ldquo;leer&rdquo; las letras de las canciones para discernir correctamente.</p>
<p>Aquí le doy mejores pistas sobre la letra de la canción:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Intento letra" height="361" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/rainy-days.png"></figure><p>Aquí le indico el género para probar suerte:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Intento género" height="498" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/italo-disco.png"></figure><p>Y aquí, ya habiéndole dicho el título de la canción, me dice esto:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Okay&hellip;" height="258" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/rainy-days-2.png"></figure><p>Al parecer no es la mejor fuente de conocimiento para encontrar música, aunque me ha salvado en alguna ocasión anterior.</p>
<p>El proyecto de GarageBand puede ser descargado desde <a href="/special/posts/finding-music-copying/Track%20N1.band.zip" target="_blank">este enlace</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Haciendo que Emby funcione un poquito más rápido</title>
      <link>https://inobtenio.com/posts/emby-optimization/</link>
      <pubDate>Mon, 24 Mar 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/emby-optimization/</guid>
      <description>Harto de lo lento que funcionaba Emby al momento de explorar las líneas de tiempo de los videos que veía, descubrí que un par de cambios y herramientas podían mejorar considerablemente la experiencia.</description><enclosure url="https://inobtenio.com/img/posts/emby-optimizations/emby-requests.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>Me gustan las películas, como a cualquiera, y he acumulado muchas a lo largo de los años, aunque no siempre las he obtenido de las maneras más formales posibles. &#x1f609;<br>
Para organizarlas y verlas suelo usar <a href="https://plex.tv/" target="_blank">Plex</a> y me gusta mucho, es una gran plataforma y merece un par de billetes de mi parte en mi opinión, pero ya que últimamente han estado tomando decisiones como mínimo <a href="https://www.plex.tv/blog/important-2025-plex-updates/#are-you-going-to-continue-to-invest-in-features-for-personal-media-plex-pass" target="_blank">curiosas</a> volví a usar con más frecuencia una instancia de <a href="https://emby.media" target="_blank">Emby</a> que uso para fines similares.</p>
<p>Todo estaba yendo relativamente bien hasta que empezó a ir relativamente mal: me di cuenta de que, a veces, las películas o videos que guardo ahí tenían dificultades para comenzar a reproducirse y/o para continuar la reproducción cuando hacía saltos en la línea de tiempo. No era un problema enorme, ya que no es común que se hagan saltos temporales cuando se ve una película, pero el problema ya había captado mi atención.</p>
<p>Pensé primero que mis discos duros estaban fallando, ya que este problema no sucedía hace un par de años cuando mudé mi biblioteca multimedia a ellos, así que realicé un par de análisis <a href="https://www.hdsentinel.com/smart/" target="_blank">SMART</a> y parecía estar todo bien con ellos. Descartado.<br>
Creí que podía ser un caso de cuello de botella de red, pero era muy improbable dado que el problema lo observé mientras se realizaba una reproducción local de los archivos y, además, recientemente había mejorado el ancho de banda de mi red local a unos nada despreciables 10 Gigabits (más de esto en algún otro post en el futuro). Descartado.<br>
Se me pasó por la mente que alguno de los nodos (computadoras/servidores) podría estar teniendo problemas en el momento de saltar de un lado al otro de la película, pero no vi en ningún caso un uso excesivo del CPU. Descartado.</p>
<p>Descarté un par de opciones más durante días y semanas, pero me di cuenta de algo interesante mientras intentaba repetir adrede el error con fines de diagnóstico. No importaba de cuántos minutos era el salto, no importaba si adelantaba 5 o 50 minutos, de todas formas había una demora notable; pero más interesante aún, me di cuenta de que si no pasaba el cursor por encima de la línea de tiempo y saltaba directamente poniéndolo justo en el punto al que quería saltar el retraso prácticamente desaparecía. Muy interesante.</p>
<h2 id="el-problema">El problema</h2>
<p>Revisando los registros en la ventana de DevTools del navegador, noté que había muchas solicitudes hechas al mismo <em>endpoint</em>, todas comenzaban por <em>Thumbnail</em> y entonces todo cobró sentido: mientras hacía <em>scrubbing</em>, o sea, mientras pasaba el cursor por la línea de tiempo para ver una pequeña previsualización de lo que pasa a lo largo de ella, se hacían decenas de solicitudes que permitían a Emby obtener las miniaturas para la previsualización. Miniaturas de poco peso, sí, pero que en el orden de decenas van retrasando de manera significativa que subsecuentes acciones se lleven a cabo.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Solicitudes de Emby para obtener miniaturas" height="200" title="Solicitudes de Emby para obtener miniaturas" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_63f6ece2c8dd273a.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_872a9173bb9a75a9.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_2bdc394eafb0f907.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_363e488378c8f93d.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_363e488378c8f93d.webp"><figcaption>Solicitudes de Emby para obtener miniaturas</figcaption></figure><p>No logré determinar cuántas solicitudes se realizan por cada ¿píxel?, ¿quizás por cada <em>em</em>?, pero resultan muchas en poco tiempo haciendo scrubbing y puede llegar a ser frustrante porque el video vuelve a reproducirse sólo cuando todas ellas (o por lo menos un buen porcentaje) han sido completadas, satisfactoriamente o no.</p>
<h2 id="la-solución">La solución</h2>
<p>Visitando los foros de Emby vi que recomendaban activar la opción de guardar las miniaturas en el mismo disco donde reside el contenido. Es una opción que yo había deliberadamente dejado desactivada porque no quería usar espacio de forma innecesaria. Añadido a esto, se recomendaba también que cada archivo de video resida dentro de su propio directorio y no tenerlos a todos dentro del mismo nivel debido a que así tanto los sistemas operativos involucrados (Debian para el NAS y Windows para el cliente que estaba usando en ese momento) como Emby mismo tendrían menos trabajo encontrando y accediendo a archivos.</p>
<p>Manos a la obra, entonces.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Opción de guardar miniaturas junto a archivos multimedia" height="292" title="Opción de guardar miniaturas junto a archivos multimedia" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a67a47433a6f4a6b.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_8134a1055113815f.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_821043d3250aa58b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a1ae955de0102d6.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a1ae955de0102d6.webp"><figcaption>Opción de guardar miniaturas junto a archivos multimedia</figcaption></figure><p>Esto inició un proceso que haciendo uso de, hm hm, FFmpeg genera archivos <a href="https://developer.roku.com/es-pe/docs/developer-program/media-playback/trick-mode/bif-file-creation.md" target="_blank">BIF</a> (creado por Roku) que encapsulan instantáneas tomadas a intervalos regulares de la totalidad del video. Hasta ahí todo bien, pero el proceso toma aproximadamente 9 segundos por gigabyte en mi hardware y con varios terabytes a cuestas iba a tomar unas buenas horas.</p>
<p>Un tiempo después, y con el proceso terminado, tenía el doble de archivos en mi directorio principal de películas/videos, cada archivo MP4 o MKV tenía un análogo en formato BIF con el mismo nombre.
Si hago un poco de memoria, recomendaban por ahí no tener tantos archivos al mismo nivel y lo que hice duplicó el número original, todo mal. Lo bueno es que el siguiente paso seguramente mejoraría las cosas.</p>
<p>Tenía que pasar cada dupla de archivos a su propia carpeta, lo malo es que Emby no se pone nada contento cuando le mueven los archivos de lugar. No importa si sólo los pones dentro de otra carpeta y ya, representa un cambio en su ubicación y eso basta.
La consecuencia de esto es que Emby no reconocería los &ldquo;nuevos&rdquo; archivos como archivos de los cuales ya tenía conocimiento y las referencias a los &ldquo;antiguos&rdquo; quedarían invalidadas, estropeando así listas de reproducción y favoritos, por ejemplo.</p>
<p>Leyendo por ahí, recomendaban el uso de los archivos <a href="https://en.wikipedia.org/wiki/.nfo" target="_blank">NFO</a> porque podrían preservar información importante sobre los archivos y siempre y cuando estén junto a los videos esa información estará disponible.</p>
<p>Hice la prueba, activé la opción de guardar todo en el disco multimedia en formato NFO y mover un par de películas a sus respectivas nuevas carpetas. Reescaneé la carpeta en Emby, refresqué los metadatos pero nada, las películas en cuestión habían desaparecido de las listas de reproducción en donde se encontraban así como de favoritos. Lo curioso es que si regresaba los archivos a su ubicación original todo volvía a la normalidad. Al parecer, la forma correcta de quitar ítems de estas listas es deliberadamente quitándolos mediante las aplicaciones o la API.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Opción de guardar metadata usando NFO" height="115" title="Opción de guardar metadata usando NFO" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_4a921b69deb8bf56.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_facf165526c05f8d.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_9d73fa7fb218f371.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_f51c26b370c53039.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_f51c26b370c53039.webp"><figcaption>Opción de guardar metadata usando NFO</figcaption></figure><figure class="width85perc">
  <img loading="lazy" alt="Opción de priorizar archivos NFO al buscar metadata" height="129" title="Opción de priorizar archivos NFO al buscar metadata" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_5263dcbb0ca54332.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_98f311de81313f0f.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_64d64602659eb8bf.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_919907adcc9e7a78.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_919907adcc9e7a78.webp"><figcaption>Opción de priorizar archivos NFO al buscar metadata</figcaption></figure><p>Claramente necesitaba otra forma de preservar mis favoritos y listas. Noté también que Emby parece darle un identificador único a cada video pero basado en un índice que va incrementándose conforme se van añadiendo nuevos archivos, lo cual significa que, al Emby considerar los archivos movidos como nuevos, tampoco conservaban sus IDs y obtenían uno nuevo.</p>
<p>Lo mejor que se me ocurrió fue resolverlo por medio de los nombres de las películas/videos. Estaba casi 100% seguro de que ninguno se repetiría y que, básicamente, funcionarían como un identificador único, pero Emby no piensa igual, así que habría que usar su API para hacer una búsqueda por el nombre y así poder llegar al verdadero ID (al nuevo) para poder volver a agregar los archivos a favoritos.</p>
<p>Algo a tener en cuenta es que cuando se hace la búsqueda mediante la API o GUI Emby no devuelve un solo resultado aunque haya una coincidencia exacta, así que tenemos que refinar la búsqueda. Una simple comparación de cadenas debería ser suficiente.</p>
<div class="tab-container" id="6f61d088">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="python">python</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-python" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> requests
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>EMBY_IP = <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>API_KEY = <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>USER_ID = <span style="color:#fc6a5d">&#34;&#34;</span> <span style="color:#6c7986"># In the format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</span>
</span></span><span style="display:flex;"><span>PARENT_ID = <span style="color:#fc6a5d">&#34;&#34;</span> <span style="color:#6c7986"># In case the videos need to be filtered by library</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>HEADERS = {<span style="color:#fc6a5d">&#34;X-Emby-Token&#34;</span>: API_KEY}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">get_favorites</span>():
</span></span><span style="display:flex;"><span>    url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Users/</span><span style="color:#fc6a5d">{</span>USER_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Items?Recursive=true&amp;IsFavorite=true&amp;ParentId=</span><span style="color:#fc6a5d">{</span>PARENT_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&amp;IncludeItemTypes=Movie&#34;</span>
</span></span><span style="display:flex;"><span>    response = requests.get(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>        items = response.json().get(<span style="color:#fc6a5d">&#34;Items&#34;</span>, [])
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">return</span> [item[<span style="color:#fc6a5d">&#34;Name&#34;</span>] <span style="color:#fc5fa3">for</span> item in items]
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Error fetching favorites:&#34;</span>, response.text)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">return</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">search_new_item_id</span>(name):
</span></span><span style="display:flex;"><span>    url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/emby/Items?Recursive=true&amp;SearchTerm=</span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    response = requests.get(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>        items = response.json().get(<span style="color:#fc6a5d">&#34;Items&#34;</span>, [])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        matching_items = [item <span style="color:#fc5fa3">for</span> item in items <span style="color:#fc5fa3">if</span> item[<span style="color:#fc6a5d">&#34;Name&#34;</span>].strip().lower() == name.strip().lower()]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">if</span> matching_items:
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">return</span> matching_items[<span style="color:#d0bf69">0</span>][<span style="color:#fc6a5d">&#34;Id&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> <span style="color:#fc5fa3">None</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">re_add_favorites</span>(old_favorites):
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">for</span> name in old_favorites:
</span></span><span style="display:flex;"><span>        new_id = search_new_item_id(name)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">if</span> new_id:
</span></span><span style="display:flex;"><span>            url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Users/</span><span style="color:#fc6a5d">{</span>USER_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/FavoriteItems/</span><span style="color:#fc6a5d">{</span>new_id<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>            response = requests.post(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Re-added favorite: </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Failed to re-add </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">: </span><span style="color:#fc6a5d">{</span>response.text<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Could not find new ID for: </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">if</span> __name__ == <span style="color:#fc6a5d">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Fetching current favorites...&#34;</span>)
</span></span><span style="display:flex;"><span>    old_favorites = get_favorites()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">input</span>(<span style="color:#fc6a5d">&#34;Move your files now and press Enter when done...&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Re-adding favorites with new paths...&#34;</span>)
</span></span><span style="display:flex;"><span>    re_add_favorites(old_favorites)
</span></span><span style="display:flex;"><span>
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">Ver más</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("6f61d088");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "Ver más";
  } else {
    container.classList.add("expanded");
    btn.textContent = "Ver menos";
  }
}
</script>

</br>
Este script obtiene los nombres de los favoritos, espera a que sean movidos y una vez se le ordene continuar, los ubica y los agrega nuevamente.
<p>Pero ahora los archivos están en tríos, ya que tenemos el archivo multimedia, el archivo de miniaturas (BIF) y el archivo de metadatos (NFO), y moverlos manualmente a sus respectivas nuevas carpetas puede ser engorroso. Es engorroso.</p>
<p>Para resolverlo usé <a href="https://www.filebot.net/" target="_blank">FileBot</a>, que es aparentemente bastante usado por usuarios en los foros de Emby y Plex para organizar archivos, mover, copiar, eliminar y más cosas. Lo malo es que tiene costo, lo peor es que es una suscripción, pero considerando que no es muy caro (tiene un precio de 6 dólares anuales al momento de escribir este artículo) y que muy probablemente tendré que realizar este tipo de operaciones con cierta frecuencia en el futuro, sumado a que tenía flojera de escribir otro script, decidí darle una oportunidad a una herramienta probada por la comunidad.</p>
<figure class="width50perc no-conversion">
  <img loading="lazy" alt="Archivos originales" height="656" title="Archivos originales" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_562209c09727408b.png 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_61df6a35a544fb23.png 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_459ac587b85b8199.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals.png"><figcaption>Archivos originales</figcaption></figure><figure class="width85perc no-conversion">
  <img loading="lazy" alt="Archivos dentro de las nuevas carpetas" height="396" title="Archivos dentro de las nuevas carpetas" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_10d40dd31cdefb9c.png 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_d4acbc03e6dd4c54.png 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_b8df8e1f1d493066.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed.png"><figcaption>Archivos dentro de las nuevas carpetas</figcaption></figure><p>Usando el preset &ldquo;File to Folder Name&rdquo; logramos que se cree una carpeta común para los tres archivos y que ese sea su nuevo hogar. En el ejemplo no hay archivos de otro tipo pero siempre que todos tengan exactamente el mismo nombre (sin contar la extensión, claro) serán puestos en el mismo directorio.</p>
<h2 id="para-ir-terminando">Para ir terminando</h2>
<p>Con los cambios anteriores el rendimiento es observable en algunos aspectos y significativo en otros. Por ejemplo, cuando se trata de escanear las bibliotecas de videos, aparentemente se realizan un poco más rápido; cuando se trata del <em>scrubbing</em>, la aparición de miniaturas sobre la línea de tiempo es casi instantánea y ya casi no hay tiempo de espera al saltar a otro punto sin importar de qué forma se haga. Parecen resultados satisfactorios.</p>
<h3 id="para-algún-día">Para algún día</h3>
<ul>
<li>Escribir un script para realizar el mismo proceso pero con listas de reproducción. No debería ser complicado, es bastante parecido al código que ya tenemos, se podría adaptar sin muchos problemas.</li>
<li>Si bien puedo usar FileBot en cuantas máquinas necesite casi sin importar la plataforma (hay soporte para Docker, Ubuntu, macOS, Windows y hasta CentOS), sería prudente reemplazarlo con un script básico. Por el momento, ya hice el gasto y haré uso del programa para estos menesteres, pero para lo que necesito unas cuantas líneas de código deberían bastar.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Videos más ligeros y usables con FFmpeg (y otros problemas con cámaras DJI)</title>
      <link>https://inobtenio.com/posts/dji-bitrate-gps/</link>
      <pubDate>Mon, 24 Feb 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/posts/dji-bitrate-gps/</guid>
      <description>Un día llegué a casa después de un viaje y al querer ver los videos que había grabado con una cámara DJI me di cuenta de que eran enormes, y con razón. Este es un ejercicio de reducción del tamaño de los archivos con la menor pérdida de calidad posible.</description><enclosure url="https://inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>No suelo ser demasiado fotogénico pero de vez en cuando disfruto de guardar recuerdos de lo que me rodea en forma de fotografías y videos, sobretodo si estoy de viaje, quién sabe cuándo volveré a ese lugar.
Normalmente el teléfono es suficiente para esos deberes pero, a veces, prefiero un dispositivo dedicado que pueda usar en su totalidad para capturar imágenes. Para ese fin cuento con dos cámaras: una de acción (<a href="https://www.dji.com/global/osmo-action-3?site=brandsite&amp;from=landing_page" target="_blank">DJI Action 3</a>) y una de <em>vlogging</em> (<a href="https://www.dji.com/global/support/product/osmo-pocket" target="_blank">DJI Osmo Pocket</a>). Son excelentes para el uso que les doy, no soy ningún tipo de profesional, sólo quiero fotografías y video con un estabilización y calidad decente, el resto es más o menos secundario, o eso pensé.</p>
<p>Alquilé un auto y grabé el camino hacia unas tiendas de segunda mano que quedaban a las afueras de la ciudad en la que me estaba quedando. Más o menos unos 90 minutos de ida y una cantidad similar de vuelta.<br>
Cuando llegó el momento de transferir los videos a un medio de almacenamiento más seguro, recordé algo que había olvidado, cada video de aproximadamente 7 minutos pesaba unos 4 GB. Se veía bien, era lo que importaba entonces.</p>
<p>Unas semanas más tarde, cuando me dispuse a transferir dichos archivos a mi servidor para poder visualizarlos en diferentes dispositivos gracias a <a href="https://immich.app/" target="_blank">Immich</a>, a parte de tardar una eternidad en la transferencia en sí, encontré un par de problemas:</p>
<ul>
<li>Como mencioné más arriba, las cámaras seccionan la grabación para que los archivos de video no pesen más de 4 GB. DJI <a href="https://forum.dji.com/forum.php?mod=redirect&amp;goto=findpost&amp;ptid=277498&amp;pid=2882469" target="_blank">dice</a> que usan FAT32 (sistema que presenta precisamente esta limitación), lo cual explica el fenómeno.</li>
<li>Dada la configuración elegida al momento de la grabación (1080p 60 fps HQ), los videos tenían un bitrate de unos 80 Mbps, u 80000 Kbps, lo cual es más del doble de lo que se encuentra en algunos discos de Blu-ray convencional y un poco menos de lo que se puede encontrar en discos de Blu-ray 4K.</li>
<li>Estas dos cámaras no tienen GPS incorporado, lo cual no es un problema ni para la mayoría de situaciones ni de personas, yo incluido, pero justo en ese caso, dada la naturaleza de la grabación, era un <em>feature</em> más que bienvenido.</li>
</ul>
<p>Veamos&hellip;</p>
<h2 id="fat32">FAT32</h2>
<p>El primer problema no es tan problemático, ni siquiera es un problema, es hasta una buena característica de este tipo de cámaras. El hecho de que los de DJI usen FAT32 y los videos individualmente no pesen más de 4 GB facilita trabajar con ellos y podría prevenir problemas serios de corrupción.</p>
<p>Transferir archivos MPEG-4 (o sea, MP4) demasiado grandes puede ser complicado. Superando los 36 GB por hora de grabación, una transferencia de ese tipo podría demorar horas entre dispositivos inalámbricos y al no ser un formato de contenedor que se lleve muy bien con los errores y la corrupción si el proceso se interrumpiera por algún motivo se tendría que reiniciar, lo cual es terriblemente engorroso.</p>
<p>Por otro lado, si los errores ocurrieran en el momento de la grabación o almacenamiento en las tarjetas MicroSD en las cámaras, al estar seccionada la grabación, se incrementan las probabilidades de recuperar una mayor porción del proyecto original.
Bueno, un poco inconveniente en algunos aspectos pero nada muy malo. Next.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Lista de archivos originales" height="117" title="Lista de videos originales" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_37eea6d03029adef.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_800d5359b057a941.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_eafc51e67dc9b905.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png"><figcaption>Lista de videos originales</figcaption></figure><h2 id="bitrate">Bitrate</h2>
<p>Este sí es un problema, aunque uno de esos que no está <strong>tan</strong> mal tener.
A ver, no tendría por qué ser un problema, yo entiendo que mientras más grande sea el archivo, lo más probable es que más información contenga el video y sea mejor cuando se trate de editar, pero creo que en este caso los archivos masivos no son de demasiada ayuda.<br>
Con un <a href="https://newatlas.com/camera-sensor-size-guide/26684/" target="_blank">sensor de 1/2.3 pulgadas</a>, la cámara no está capturando tanta luz (comparativamente hablando, claro está) y todo ese peso adicional viene de ¿el ruido?, no lo sé. Aún así, más información no suele caer mal, pero en este caso creo que puedo deshacerme de  unos cuantos bytes sin sacrificar mucha calidad de imagen. Por lo menos es la ida.</p>
<p>Con eso en mente, la tasa de ~36 GB por hora (80000 Kbps) es un innecesariamente alta para videos caseros o personales que seguramente querríamos subir a redes sociales, con esas tasas de bits demoraremos mucho en hacer cualquier cosa con los videos, ya sea editarlos, subirlos a alguna nube o simplemente moverlos a un disco externo. Es aquí donde entra <a href="https://www.ffmpeg.org/" target="_blank">FFmpeg</a> al juego.</p>
<p>FFmpeg (pronunciado /&lsquo;ɛfɛfɛm&rsquo;pɛɡ/ 😉), me ayudó a reducir el tamaño de los archivos sin reducir su calidad de forma muy apreciable. Estos son los parámetros que usé:</p>
<pre tabindex="0"><code>ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i {input} -c:v h264_qsv -global_quality 25 {output}
</code></pre><ul>
<li><code>-hide_banner</code> previene la impresión de cosas como el banner, cosas del copyright y opciones con las que fue compilada la librería, datos que son de poco uso la mayoría de veces.</li>
<li><code>-loglevel error</code> muestra todos los errores.</li>
<li><code>-hwaccel qsv</code> hace uso de los codificadores de hardware de los procesadores Intel Quick Sync Video (qsv) que aceleran el proceso significativamente.</li>
<li><code>-c:v h264_qsv</code> especifica el códec que vamos a usar, uso h.264 por compatibilidad.</li>
<li><code>-global_quality 25</code> establece el nivel de calidad que obtendremos, mientras menos sea ese número, mayor será la calidad, la idea es tener un equilibrio y 25 está bastante bien para la reducción.</li>
</ul>
<p>El resultado es bastante prometedor, los videos se reducen de alrededor de 4 GB a unos nada despreciables ~850 MB, una reducción de hasta el 80%.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Original" height="405" title="Fotograma original" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_e60b3427507329cf.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_92d5e78ca551d21c.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_306380247da3b127.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_819a9db97711311c.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_819a9db97711311c.webp"><figcaption>Fotograma original</figcaption></figure><figure class="width85perc">
  <img loading="lazy" alt="Procesado" height="405" title="Fotograma después de la dieta" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_8b4a27b8d27605d9.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_70e2c3654c2c2799.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_e574b7be73a5c645.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_caff4867a0acf273.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_caff4867a0acf273.webp"><figcaption>Fotograma después de la dieta</figcaption></figure><p class="idea"><em>Imágenes comprimidas para mejorar el rendimiento de la página. <a href="/img/posts/dji-bitrate-gps/frame1_original.png" target="_blank">Aquí</a> y <a href="/img/posts/dji-bitrate-gps/frame1_processed.png" target="_blank">aquí</a> se pueden encontrar los archivos originales.</em></p>
<p>Pero no confíen simplemente en mis (o sus) ojos, existen algoritmos de comparación de imágenes y videos como <a href="https://netflixtechblog.com/toward-a-practical-perceptual-video-quality-metric-653f208b9652" target="_blank">VMAF</a>, que desarrolló Netflix, o <a href="https://www.imatest.com/docs/ssim/" target="_blank">SSIM</a>, desarrollado en universidades de Estados Unidos, que pueden ayudarnos a tomar decisiones sobre cuáles son los mejores parámetros de codificación para nuestros videos según las necesidades.</p>
<p>Por ahora elegiré SSIM debido a que para usar el algoritmo de Netflix (por cierto, recomiendo visitar su blog técnico de vez en cuando) es necesario completar un par de pasos con anterioridad, como convertirlos al espacio de color <a href="https://es.wikipedia.org/wiki/YUV" target="_blank">YUV</a>, por ejemplo. La flojera me gana en estos momentos.</p>
<p>SSIM, el índice de similitud estructural, funciona simulando la percepción que tendría un ser humano sobre dos imágenes y su similitud, incluyendo aspectos como la luminosidad y el contraste. Hay más de una forma de calcularlo, yo opté por usar FFmpeg que lo trae incluido de &ldquo;fábrica&rdquo;:</p>
<pre tabindex="0"><code>ffmpeg.exe -i {input1} -i {input2} -lavfi ssim=stats_file={output} -f null -
</code></pre><p>Este comando nos devuelve un archivo largo en donde podemos ver la comparación entre cada uno de los fotogramas de los dos videos, dándonos un valor general y valores parciales por los tres componentes del espacio YUV (parece que no había que convertir los videos antes de usar VMAF después de todo &#x1f61b;).</p>
<p>Tiene una estructura similar a esta:</p>
<figure class="width50perc">
  <img loading="lazy" alt="SSIM" height="268" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_f368d60c1c880a89.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_60d03f9dc97cc581.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_4d9bd3f8a23d79dc.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract.png"></figure><p>Al hacer un pequeño análisis estadístico de los resultados tanto para valores de 24 como de 25 para la configuración <code>-global_quality</code> del comando usado para reducir la calidad, 24 gana por poco, un margen que me pareció aceptable. Esta corta distancia numérica, sumado a que en nivel 25 el cambio no era drástico comparado con la reducción de tamaño me hizo ratificar mi decisión.</p>
<p>Aquí unos cuantos números cortesía de <a href="https://www.calculatorsoup.com/" target="_blank">CalculatorSoup</a> (Wolfram Alpha no pudo con tanta data en su versión gratuita &#x2639;&#xfe0f;) del promedio de similitud de fotogramas entre videos, así como la desviación estándar de los valores. A mayor promedio y menos desviación estándar (σ), mejor.</p>
<figure class="half">
  <img loading="lazy" alt="Calidad nivel 24" height="987" title="Calidad nivel 24" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_c3993c9b78c8cf92.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_2a0e4c536d30622a.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_d3b374cd865eb15f.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_f2df079f06733943.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_f2df079f06733943.webp"><figcaption>Calidad nivel 24</figcaption></figure><figure class="half">
  <img loading="lazy" alt="Calidad nivel 25" height="987" title="Calidad nivel 25" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_71fd49063f1d31f0.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_2a15bc8bd491f205.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_7930fece935b49a2.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_ef5cc8d23fa1ab37.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_ef5cc8d23fa1ab37.webp"><figcaption>Calidad nivel 25</figcaption></figure><p>Teniendo en cuenta que, en general, un valor de MSSIM (SSIM promedio) mayor o igual a 0.99 significa que dos imágenes o videos son <a href="https://www.researchgate.net/publication/342435717_Understanding_SSIM" target="_blank"><strong>indistinguibles</strong></a> (Sec. 4.1 Luminance), los valores resultantes parecen satisfactorios.</p>
<!-- +--
\begin{aligned}
  \Pr(\mu-1\sigma \le X \le \mu+1\sigma) & \approx 68.27\% \\
  \Pr(\mu-2\sigma \le X \le \mu+2\sigma) & \approx 95.45\% \\
  \Pr(\mu-3\sigma \le X \le \mu+3\sigma) & \approx 99.73\%
\end{aligned}
--+
Fórmulas de la [regla 68-95-99.7](https://es.wikipedia.org/wiki/Regla_68-95-99.7)
{class="caption"} -->
<figure class="width60perc">
  <img loading="lazy" alt="&ldquo;Fórmulas de la regla 68-95-99.7&rdquo;" height="173" title="Fórmulas de la [regla 68-95-99.7](https://es.wikipedia.org/wiki/Regla_68-95-99.7)" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_fa1dd6325e97f968.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_5a67b3a579bf21d3.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_326aed80492e15ff.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_7c9b1faeddf98c5c.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_7c9b1faeddf98c5c.webp"><figcaption>Fórmulas de la <a href="https://es.wikipedia.org/wiki/Regla_68-95-99.7" target="_blank">regla 68-95-99.7</a></figcaption></figure><p>Por un lado, con calidad 24, la más alta de las dos, tenemos un promedio de 0.9862 con un σ de 0.0084. Esto significa que, según la fórmula de arriba, el ~95% del tiempo los videos tienen un nivel de similitud de entre 96.94% y 100% y que más del 99% del tiempo ese nivel está entre 96.1% y 100%. Además, en promedio son 98.62% similares. Bastante bien.</p>
<p>Por el otro, con calidad 25, el promedio es de 0.9844 con un σ de 0.0109, lo cual significa que el ~95% del tiempo los videos tienen un nivel de similitud de entre 96.26% y 100% y que más del 99% del tiempo ese nivel está entre 95.17% y 100%. Además, en promedio son 98.44% similares.</p>
<p>Parece un precio justo a pagar en términos de calidad por ahorrar varios bytes aquí y allá.</p>
<p>¿Fue innecesario todo esto? Muy probablemente, pero me hacía ilusión tener números respaldando o desaprobando mi decisión; felizmente, sucedió algo más cercano a lo primero.</p>
<h2 id="gps">GPS</h2>
<p>Me di cuenta de la falta de información de geolocalización de los videos al añadirlos a Immich, en donde se indica en un mapa en dónde fueron capturados si tienen los metadatos disponibles. Lo bueno es que cuenta con un editor de metadatos, el cual usé para guardar las coordenadas del punto de inicio de cada video; tuve que verlos y contrastar con Google Maps para poder ubicarlos exactamente.<br>
Una vez geolocalizados los videos, Immich creó archivos Exif externos correspondientes a cada uno de los videos, todos ellos compartían el nombre con su contraparte videográfica aunque con una extensión distinta: <code>xmp</code>.</p>
<p>Hasta ahí todo bien, si bien en este caso los metadatos Exif son externos, al tener el mismo nombre de archivo pueden ser recogidos por diversos reproductores de video, no debería ser un problema; sin embargo, yo quería que estuvieran todos los datos en los mismos videos, es más fácil organizarlos de esa forma.</p>
<p>Usé <a href="https://exiftool.org/" target="_blank">ExifTool</a> para solucionarlo:</p>
<pre tabindex="0"><code>exiftool -gpsposition -c &#34;%.7f&#34; {input}
</code></pre><ul>
<li><code>-gpsposition -c &quot;%.7f&quot;</code> extrae los datos de geolocalización en el formato &ldquo;%.7f&rdquo;, que es un número entero con siete cifras decimales (ej. -31.8267316).</li>
</ul>
<pre tabindex="0"><code>exiftool -TagsFromFile {input} -All:All -XMP -ExtractEmbedded -overwrite_original {output}
</code></pre><ul>
<li><code>-TagsFromFile</code> ayuda a tomar las etiquetas Exif guardadas en los archivos que creó Immich previamente.</li>
<li><code>-All:All</code> indica a la herramienta tomar en cuenta todas las etiquetas Exif que pueda.</li>
<li><code>-XMP</code> lo mismo que lo anterior pero con etiquetas XMP, que suelen ser metadatos no estándar definidos por cada fabricante de cámaras o desarrollador de software de video.</li>
</ul>
<p>Había un pequeñísimo problema adicional, desgraciadamente: <a href="https://forum.dji.com/forum.php?mod=redirect&amp;goto=findpost&amp;ptid=304071&amp;pid=3209186" target="_blank">al parecer</a>, las cámaras DJI necesitan conectarse a un teléfono o tablet por Bluetooth después de haber estado desconectadas por <em>mucho tiempo</em> para poder obtener la fecha y hora adecuadas. Esta característica causó que la hora de mis videos estuviera atrasada unos 90 minutos. No sé la cantidad exacta de minutos porque no tenía una buena referencia más que una grabación que hice con mi teléfono, así que calculo que debo tener un error de 3 minutos como máximo. Aceptable.</p>
<p>Tenía que cambiar esas horas en los archivos y en las etiquetas Exif, ya que si sólo lo hacía con los archivos, reproductores de video que leen dichas etiquetas mostrarían mal las marcas de tiempo y software de organización de medios, como Immich, no colocarían de forma adecuada los videos en las líneas de tiempo.</p>
<p>Cambiar de fecha los archivos era simple. En Windows (PowerShell) sería más o menos así:</p>
<div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>(<span style="color:#d0a8ff">Get-Item</span> {input}).LastWriteTime = {new_date}
</span></span></code></pre></div><p>No olvidar que <code>new_date</code> debe tener el formato correcto, en este caso opté por <code>MM/dd/yyyy HH:mm</code> ya que fue el que funcionó en mis pruebas en mi instalación de Windows.</p>
<p style="font-size:13px;">(estamos cerca)</p>
<p>Peeeeero la intención era quitarle 90 minutos al tiempo de creación/modificación actual de los archivos, así que tuve que extraer y restar. Lo bueno es que es una operación no destructiva, así que se podía dejar para el último paso una vez que los procesos de FFmpeg y ExifTool hubieran culminado.</p>
<p>Una cosita antes de tener el script completo: cuando se trata de etiquetas exif, por lo menos en mi caso, la zona horaria de las marcas de tiempo siempre era UTC y estaban en el formato <code>yyyy-MM-dd HH:mm:ss</code>, así que había que tener eso en cuenta para ese paso, mas no para el cambio de hora de los archivos en sí.</p>
<p>Bueno, con todo eso en cuenta y algunas cosas más, el script terminaría luciendo así (y ahora con versión bash incluida):</p>
<div class="tab-container" id="f97a3597">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="powershell">powershell</button>
	    <button class="tab-label " data-lang="bash">bash</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-powershell" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span><span style="color:#41a1c0">$originals_path</span> = {originals_path}
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$processed_path</span> = {processed_path}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#d0a8ff">Get-ChildItem</span> <span style="color:#41a1c0">$originals_path</span> -Filter *.mp4 | <span style="color:#d0a8ff">Sort-Object</span> -Property Name | <span style="color:#d0a8ff">Foreach-Object</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Original file: </span>$(<span style="color:#41a1c0">$_</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Reducing video bitrate&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i <span style="color:#41a1c0">$_</span>.Fullname -c:v h264_qsv -global_quality <span style="color:#d0bf69">25</span> {original_file}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># qs stands for Quick Sync</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$qs_file</span> = (<span style="color:#d0a8ff">Get-Item</span> <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$processed_path</span><span style="color:#fc6a5d">\</span>$(<span style="color:#41a1c0">$_</span>.Basename)<span style="color:#fc6a5d">.mp4&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Bitrate-reduced file: </span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the current creation datetime (wrong creation date)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$wrongCreationDateTime</span> = <span style="color:#fc6a5d">&#34;{0:yyyy-MM-dd HH:mm}&#34;</span> -f $(<span style="color:#41a1c0">$_</span>.CreationTime)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the right creation datetime but in Windows format</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span> = [datetime]::parseexact(<span style="color:#41a1c0">$wrongCreationDateTime</span>, <span style="color:#fc6a5d">&#39;yyyy-MM-dd HH:mm&#39;</span>, $null).AddMinutes(<span style="color:#d0bf69">-90</span>).toString(<span style="color:#fc6a5d">&#39;MM/dd/yyyy HH:mm&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the right creation datetime but in Exif format (UTC with seconds)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span> = [datetime]::parseexact(<span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span>, <span style="color:#fc6a5d">&#39;MM/dd/yyyy HH:mm&#39;</span>, $null).AddMinutes(<span style="color:#d0bf69">300</span>).toString(<span style="color:#fc6a5d">&#39;yyyy:MM:dd HH:mm:00&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Original file: </span>$(<span style="color:#41a1c0">$_</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;QS file: </span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Wrong creation datetime: </span><span style="color:#41a1c0">$wrongCreationDateTime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Right creation datetime in Windows format: </span><span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Right datetime in ExifTool UTC format with seconds: </span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;ExifTool copying all tags from original...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    exiftool -TagsFromFile <span style="color:#41a1c0">$_</span>.Fullname -All:All -XMP -ExtractEmbedded -overwrite_original <span style="color:#41a1c0">$qs_file</span>.Fullname
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Extracting coordinates info from XMP files</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$coords</span> = exiftool -gpsposition -c <span style="color:#fc6a5d">&#34;%.7f&#34;</span> <span style="color:#fc6a5d">&#34;</span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">.xmp&#34;</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$coords</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;ExifTool setting geoposition and updated timestaps...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    exiftool <span style="color:#fc6a5d">&#34;-CreateDate=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> `
</span></span><span style="display:flex;"><span>     <span style="color:#fc6a5d">&#34;-ModifyDate=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> `
</span></span><span style="display:flex;"><span>     <span style="color:#fc6a5d">&#34;-Track*Date=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> `
</span></span><span style="display:flex;"><span>     <span style="color:#fc6a5d">&#34;-Media*Date=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> `
</span></span><span style="display:flex;"><span>     -gpsposition=<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$coords</span><span style="color:#fc6a5d">&#34;</span> `
</span></span><span style="display:flex;"><span>     -overwrite_original <span style="color:#41a1c0">$qs_file</span>.Fullname
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    (<span style="color:#d0a8ff">Get-Item</span> <span style="color:#41a1c0">$qs_file</span>).LastWriteTime = <span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;=================================================================================================&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span></code></pre></div>
            </div>
            <div class="tab-content content-bash" >
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#fd8f3f">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#fd8f3f"></span>
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$originals_path</span> = {originals_path}
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$processed_path</span> = {processed_path}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">for</span> filename in <span style="color:#fc6a5d">`</span><span style="color:#41a1c0">$originals_path</span>/*.mp4<span style="color:#fc6a5d">`</span>; <span style="color:#fc5fa3">do</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Original file: </span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Reducing video bitrate&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$qs_filename</span> =  $<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$processed_path</span><span style="color:#fc6a5d">/</span><span style="color:#fc5fa3">$(</span>basename <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc5fa3">)</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i <span style="color:#41a1c0">$filename</span> -c:v h264_qsv -global_quality <span style="color:#d0bf69">25</span> <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Bitrate-reduced file: </span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$wrong_datetime</span> = <span style="color:#fc5fa3">$(</span>date -r <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$right_datetime</span> = <span style="color:#fc5fa3">$(</span>date -d <span style="color:#fc6a5d">&#34;&#34;</span><span style="color:#41a1c0">$wrong_datetime</span><span style="color:#fc6a5d">&#34;  90 minutes&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span> = <span style="color:#fc5fa3">$(</span>date -d <span style="color:#fc6a5d">&#34;&#34;</span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;  300 minutes&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M:%S&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Original file: </span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;QS file: </span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Wrong creation datetime: </span><span style="color:#41a1c0">$wrong_datetime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Right datetime: </span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Right datetime in ExifTool UTC format with seconds: </span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;ExifTool copying all tags from original...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  exiftool -TagsFromFile <span style="color:#41a1c0">$filename</span> -All:All -XMP -ExtractEmbedded -overwrite_original <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$coords</span> = exiftool -gpsposition -c <span style="color:#fc6a5d">&#34;%.7f&#34;</span> <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">.xmp&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#41a1c0">$coords</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;ExifTool setting geoposition and updated timestaps...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  exiftool <span style="color:#fc6a5d">&#34;-CreateDate=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-ModifyDate=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-Track*Date=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-Media*Date=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          -gpsposition=<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$coords</span><span style="color:#fc6a5d">&#34;</span> -overwrite_original <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  touch -d <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;=================================================================================================&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">done</span>
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">Ver más</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("f97a3597");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "Ver más";
  } else {
    container.classList.add("expanded");
    btn.textContent = "Ver menos";
  }
}
</script>

<h2 id="para-ir-terminando">Para ir terminando</h2>
<p>Todo este proceso me dejó con videos de mucho menor tamaño, con la información correcta sobre en dónde fueron capturados y con archivos con las fechas y horas correctas. Pasamos del panorama de la imagen de portada de este artículo a esto:</p>
<figure class="width85perc">
  <img loading="lazy" alt="Lista de archivos procesados" height="117" title="Lista de videos procesados" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_91f2537aecd47bc7.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_ff3c0c9af7619f2e.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_fa3e5d540706d009.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after.png"><figcaption>Lista de videos procesados</figcaption></figure><p>De esta forma es más fácil editarlos, transmitirlos o simplemente reproducirlos.</p>
<p>Yo sé que mientras más información haya en un video, más flexibilidad otorgará al momento de ser procesado; al fin y al cabo, las computadoras no pueden mágicamente inventar información prácticamente de la nada. ¿O sí?
Es por eso que quizás termine usando todos los fragmentos originales para juntarlos y formar un video largo al que después pueda aplicar todo lo aprendido aquí para terminar con un archivo razonablemente pesado.</p>
<p>Lo importante es que si hubo aprendizaje no fue una pérdida de tiempo, aunque el tiempo invertido en geolocalizar cada fragmento por separado difícilmente sea recompensado, a menos que pueda generar una suerte de ruta descrita por las coordenadas para luego animar un mapa con mi recorrido en tiempo real y superponerlo al video o algo por el estilo.</p>
<p>Quién sabe. &#x1f609;</p>
<h3 id="por-qué-no-premiere-resolve-o-simplemente-movie-maker">¿Por qué no Premiere, Resolve o simplemente Movie Maker?</h3>
<p>Bueno, me parece que FFmpeg es no sólo más versátil si no también más divertido de usar cuando se trabaja con varios archivos a la vez and cuando no se sabe bien qué hacer, es una herramienta que permite la experimentación casi extrema. Además, esto se trata de obtener diversión mientras se resuelven problemas; hasta cierto punto, claro.<br>
Obviamente, es también más difícil que usar Resolve, que uso típicamente, pero a veces me siento más cómodo en la terminal y la iba a usar de todas formas debido a ExifTool.<br>
Además, en esta casa se usa FFmpeg, es gratuito y de código abierto y permite usar la tarjeta de video para la codificación; aparte, tiene una comunidad enorme.</p>
<p>Usaré Resolve para la corrección del color, creo que no quiero tener que lidiar con FFmpeg cuando se trate de usar LUTs. &#x1f614;</p>
<h3 id="para-algún-día">Para algún día</h3>
<ul>
<li>Mejorar el código, seguramente se puede optimizar y no hacer dos llamadas a ExifTool, ya que es una operación destructiva y consume bastante tiempo. Aparte, no es el código más lindo del mundo.</li>
<li>Darle un poco de color al video, está un poco feo así.</li>
<li>Recordar enlazar las cámaras a un teléfono antes de grabar para evitar desfases temporales.</li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
