263 lines
12 KiB
PHP
263 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* AI disclosure labels for media (EU AI Act, Art. 50).
|
|
*
|
|
* Images that were created or modified with AI carry a label wherever Raiju
|
|
* renders them, shown when a visitor lingers on the image and always available to
|
|
* screen readers (alt text). The label kind ('generated' or 'modified') is stored per
|
|
* media file in content/media-meta.json and managed in the media library; the
|
|
* media API carries it along when files are renamed, moved, trashed or restored.
|
|
*
|
|
* Rendering: raijuAiLabelHtml($src) returns the label markup for an image source
|
|
* (empty for unlabelled files). editableImage(), editableImageSplit() and the core
|
|
* blocks call it automatically. The label artwork lives in css/ai-labels/: English
|
|
* ("AI generated"/"AI modified") on every page by default; with Settings → Language →
|
|
* AI labels = page language, German pages use the German artwork ("KI generiert"). The
|
|
* alt text always follows the page language. In edit mode, js/inline-editor.js keeps
|
|
* labels in sync with image changes (window.RJ_AI_LABELS).
|
|
*/
|
|
|
|
require_once __DIR__ . '/json-store.php';
|
|
|
|
const RAIJU_AI_LABEL_KINDS = ['generated', 'modified'];
|
|
|
|
function raijuMediaMetaPath(): string {
|
|
return defined('RAIJU_MEDIA_META_PATH') ? RAIJU_MEDIA_META_PATH : dirname(__DIR__) . '/content/media-meta.json';
|
|
}
|
|
|
|
/** Stored media metadata: [type => [relative name => ['ai' => kind]]]. */
|
|
function raijuMediaMeta(bool $reload = false): array {
|
|
static $cache = null;
|
|
if ($cache === null || $reload) {
|
|
$path = raijuMediaMetaPath();
|
|
$data = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
|
|
$cache = is_array($data) ? $data : [];
|
|
}
|
|
return $cache;
|
|
}
|
|
|
|
/** Media type and name relative to the type folder for a public source, e.g. /assets/images/a/b.webp. */
|
|
function raijuMediaRefFromSrc(string $src): ?array {
|
|
$path = rawurldecode((string)(parse_url($src, PHP_URL_PATH) ?? ''));
|
|
foreach (['image' => 'assets/images/', 'video' => 'assets/videos/'] as $type => $marker) {
|
|
$position = strpos($path, $marker);
|
|
if ($position === false) continue;
|
|
$name = substr($path, $position + strlen($marker));
|
|
if ($name === '' || str_contains($name, '..') || str_starts_with($name, '/')) return null;
|
|
return ['type' => $type, 'name' => $name];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Label kind of a media file: 'generated', 'modified' or ''. */
|
|
function raijuMediaAiLabelFor(string $type, string $name): string {
|
|
$kind = raijuMediaMeta()[$type][$name]['ai'] ?? '';
|
|
return in_array($kind, RAIJU_AI_LABEL_KINDS, true) ? $kind : '';
|
|
}
|
|
|
|
/** Label kind for a public image or video source. */
|
|
function raijuMediaAiLabel(string $src): string {
|
|
$ref = raijuMediaRefFromSrc($src);
|
|
return $ref ? raijuMediaAiLabelFor($ref['type'], $ref['name']) : '';
|
|
}
|
|
|
|
/** Set ('generated', 'modified') or remove ('') the label of a media file. */
|
|
function raijuSetMediaAiLabel(string $type, string $name, string $kind): bool {
|
|
$kind = in_array($kind, RAIJU_AI_LABEL_KINDS, true) ? $kind : '';
|
|
if ($kind === '' && !isset(raijuMediaMeta()[$type][$name])) return true;
|
|
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name, $kind) {
|
|
if ($kind !== '') {
|
|
$data[$type][$name]['ai'] = $kind;
|
|
return true;
|
|
}
|
|
unset($data[$type][$name]['ai']);
|
|
raijuMediaMetaPrune($data, $type, $name);
|
|
return true;
|
|
});
|
|
raijuMediaMeta(true);
|
|
return $ok;
|
|
}
|
|
|
|
/**
|
|
* Source and licence of an imported image: media-meta[type][name]['credit'].
|
|
* Keys: source, author, authorUrl, sourceUrl, license, attribution, importedAt, importedBy.
|
|
*/
|
|
function raijuSetMediaCredit(string $type, string $name, array $credit): bool {
|
|
$clean = [];
|
|
foreach (['source', 'author', 'authorUrl', 'sourceUrl', 'license', 'attribution', 'importedAt', 'importedBy'] as $key) {
|
|
if (isset($credit[$key]) && is_string($credit[$key]) && $credit[$key] !== '') {
|
|
$clean[$key] = mb_substr($credit[$key], 0, 300, 'UTF-8');
|
|
}
|
|
}
|
|
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name, $clean) {
|
|
if ($clean) $data[$type][$name]['credit'] = $clean; else unset($data[$type][$name]['credit']);
|
|
raijuMediaMetaPrune($data, $type, $name);
|
|
return true;
|
|
});
|
|
raijuMediaMeta(true);
|
|
return $ok;
|
|
}
|
|
|
|
function raijuMediaCreditFor(string $type, string $name): array {
|
|
$credit = raijuMediaMeta()[$type][$name]['credit'] ?? [];
|
|
return is_array($credit) ? $credit : [];
|
|
}
|
|
|
|
/** Carry metadata along when a media file is renamed, moved, trashed or restored. */
|
|
function raijuMoveMediaMeta(string $fromType, string $fromName, string $toType, string $toName): bool {
|
|
if (!isset(raijuMediaMeta()[$fromType][$fromName]) || ($fromType === $toType && $fromName === $toName)) return true;
|
|
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($fromType, $fromName, $toType, $toName) {
|
|
if (!isset($data[$fromType][$fromName])) return false;
|
|
$data[$toType][$toName] = $data[$fromType][$fromName];
|
|
unset($data[$fromType][$fromName]);
|
|
raijuMediaMetaPrune($data, $fromType, $fromName);
|
|
return true;
|
|
});
|
|
raijuMediaMeta(true);
|
|
return $ok;
|
|
}
|
|
|
|
/** Forget the metadata of a deleted file, or of a whole namespace when $name is null. */
|
|
function raijuForgetMediaMeta(string $type, ?string $name = null): bool {
|
|
$meta = raijuMediaMeta();
|
|
if ($name === null ? !isset($meta[$type]) : !isset($meta[$type][$name])) return true;
|
|
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name) {
|
|
if ($name === null) {
|
|
unset($data[$type]);
|
|
return true;
|
|
}
|
|
unset($data[$type][$name]);
|
|
raijuMediaMetaPrune($data, $type, $name);
|
|
return true;
|
|
});
|
|
raijuMediaMeta(true);
|
|
return $ok;
|
|
}
|
|
|
|
function raijuMediaMetaPrune(array &$data, string $type, string $name): void {
|
|
if (isset($data[$type][$name]) && empty($data[$type][$name])) unset($data[$type][$name]);
|
|
if (isset($data[$type]) && empty($data[$type])) unset($data[$type]);
|
|
}
|
|
|
|
/**
|
|
* Best-effort detection from embedded provenance: the IPTC digital source type,
|
|
* written to XMP or C2PA manifests by image generators and generative editing.
|
|
*/
|
|
function raijuDetectAiLabel(string $file): string {
|
|
$size = @filesize($file);
|
|
if (!$size || $size > 16 * 1024 * 1024) return '';
|
|
$bytes = (string)@file_get_contents($file);
|
|
if (stripos($bytes, 'digitalsourcetype/compositeWithTrainedAlgorithmicMedia') !== false
|
|
|| stripos($bytes, 'digitalsourcetype/compositeSynthetic') !== false) {
|
|
return 'modified';
|
|
}
|
|
if (stripos($bytes, 'digitalsourcetype/trainedAlgorithmicMedia') !== false) {
|
|
return 'generated';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** Two-letter language of the rendered page. */
|
|
function raijuAiLabelLanguage(?string $lang = null): string {
|
|
$lang = $lang ?? ($GLOBALS['currentLang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'));
|
|
return strtolower(substr((string)$lang, 0, 2));
|
|
}
|
|
|
|
/** Accessible label text in the page language. */
|
|
function raijuAiLabelText(string $kind, ?string $lang = null): string {
|
|
$texts = [
|
|
'cs' => ['generated' => 'Vytvořeno pomocí AI', 'modified' => 'Upraveno pomocí AI'],
|
|
'de' => ['generated' => 'KI-generiert', 'modified' => 'KI-modifiziert'],
|
|
'en' => ['generated' => 'AI-generated', 'modified' => 'AI-modified'],
|
|
'es' => ['generated' => 'Generado con IA', 'modified' => 'Modificado con IA'],
|
|
'fr' => ['generated' => 'Généré par IA', 'modified' => 'Modifié par IA'],
|
|
'it' => ['generated' => 'Generato con IA', 'modified' => 'Modificato con IA'],
|
|
'pl' => ['generated' => 'Wygenerowano przez AI', 'modified' => 'Zmodyfikowano przez AI'],
|
|
'pt' => ['generated' => 'Gerado por IA', 'modified' => 'Modificado por IA'],
|
|
'tr' => ['generated' => 'Yapay zekâ ile oluşturuldu', 'modified' => 'Yapay zekâ ile değiştirildi'],
|
|
];
|
|
$lang = raijuAiLabelLanguage($lang);
|
|
return ($texts[$lang] ?? $texts['en'])[$kind] ?? '';
|
|
}
|
|
|
|
/**
|
|
* Label artwork mode (settings.json → general.aiLabelArtwork): 'en' shows the English
|
|
* artwork on every page (default), 'page' the German artwork on German pages.
|
|
*/
|
|
function raijuAiLabelArtworkMode(bool $reload = false): string {
|
|
static $mode = null;
|
|
if ($mode === null || $reload) {
|
|
$path = defined('SETTINGS_PATH') ? SETTINGS_PATH : dirname(__DIR__) . '/content/settings.json';
|
|
$settings = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
|
|
$mode = is_array($settings) && ($settings['general']['aiLabelArtwork'] ?? '') === 'page' ? 'page' : 'en';
|
|
}
|
|
return $mode;
|
|
}
|
|
|
|
/** Label markup for an image source; empty when the file carries no AI label. */
|
|
function raijuAiLabelHtml(string $src, ?string $lang = null): string {
|
|
$kind = raijuMediaAiLabel($src);
|
|
return $kind === '' ? '' : raijuAiLabelMarkup($kind, $lang);
|
|
}
|
|
|
|
/** Label markup for a label kind: English artwork, or German on German pages if the site opts in. */
|
|
function raijuAiLabelMarkup(string $kind, ?string $lang = null): string {
|
|
if (!in_array($kind, RAIJU_AI_LABEL_KINDS, true)) return '';
|
|
$lang = raijuAiLabelLanguage($lang);
|
|
$artwork = (raijuAiLabelArtworkMode() === 'page' && $lang === 'de' ? 'de' : 'en') . '-' . $kind;
|
|
// Width at the default height of 22px, from the artwork's aspect ratio
|
|
$widths = ['de-generated' => 111, 'de-modified' => 122, 'en-generated' => 114, 'en-modified' => 102];
|
|
$GLOBALS['raijuAiLabelsRendered'] = true;
|
|
$base = (string)($GLOBALS['basePath'] ?? '');
|
|
$text = htmlspecialchars(raijuAiLabelText($kind, $lang), ENT_QUOTES, 'UTF-8');
|
|
|
|
return '<span class="rj-ai-label rj-ai-label--' . $kind . '">'
|
|
. '<img class="rj-ai-label__badge" src="' . htmlspecialchars($base . 'css/ai-labels/' . $artwork . '.svg', ENT_QUOTES, 'UTF-8')
|
|
. '" width="' . $widths[$artwork] . '" height="22" alt="' . $text . '" decoding="async"></span>';
|
|
}
|
|
|
|
/**
|
|
* Image markup followed by its label. With $frame, a labelled image and its label
|
|
* share a positioned frame, so the label sits on the image even when text follows.
|
|
*/
|
|
function raijuAiLabelAttach(string $imageHtml, string $src, bool $frame = false): string {
|
|
$label = raijuAiLabelHtml($src);
|
|
if ($label === '') return $imageHtml;
|
|
return $frame
|
|
? '<span class="rj-ai-media">' . $imageHtml . $label . '</span>'
|
|
: $imageHtml . $label;
|
|
}
|
|
|
|
/** Wrap already rendered image markup (image + label) in a label frame when it carries a label. */
|
|
function raijuAiLabelFrame(string $html): string {
|
|
return str_contains($html, 'class="rj-ai-label ') ? '<span class="rj-ai-media">' . $html . '</span>' : $html;
|
|
}
|
|
|
|
/**
|
|
* Label data for the inline editor (window.RJ_AI_LABELS): labelled files and the label
|
|
* markup per kind, so labels follow image swaps and media library changes right away.
|
|
*/
|
|
function raijuAiLabelEditorConfig(?string $lang = null): array {
|
|
$files = [];
|
|
foreach (['image', 'video'] as $type) {
|
|
foreach (array_keys((array)(raijuMediaMeta()[$type] ?? [])) as $name) {
|
|
$kind = raijuMediaAiLabelFor($type, (string)$name);
|
|
if ($kind !== '') $files[$type][(string)$name] = $kind;
|
|
}
|
|
}
|
|
// Templates only: they must not count as labels rendered on the page
|
|
$rendered = $GLOBALS['raijuAiLabelsRendered'] ?? false;
|
|
$markup = [];
|
|
foreach (RAIJU_AI_LABEL_KINDS as $kind) {
|
|
$markup[$kind] = raijuAiLabelMarkup($kind, $lang);
|
|
}
|
|
$GLOBALS['raijuAiLabelsRendered'] = $rendered;
|
|
|
|
return ['files' => (object)array_map(fn($names) => (object)$names, $files), 'markup' => $markup];
|
|
}
|
|
|
|
/** Whether a label was rendered on this page (loads js/ai-labels.js). */
|
|
function raijuAiLabelsRendered(): bool {
|
|
return !empty($GLOBALS['raijuAiLabelsRendered']);
|
|
}
|