Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added frontend/public/stamps/ai-black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/stamps/ai-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/stamps/generated-black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/stamps/generated-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/stamps/modified-black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/stamps/modified-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 23 additions & 1 deletion frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
import HowItWorks from './components/HowItWorks.svelte';
import LegalModal from './components/LegalModal.svelte';
import CropModal from './components/CropModal.svelte';
import AILabelOptions from './components/AILabelOptions.svelte';
import { createProgress } from './stores/progress.svelte.js';
import { BUNDLE_PRESETS, PACK_PRESETS } from './lib/presets.js';

const progress = createProgress();

let files = $state([]); // [{ file, url }]
let selectedPresets = $state([]); // preset name strings
let aiLabelEnabled = $state(false);
let aiLabelStyle = $state('generated');
let aiLabelColor = $state('black');
let aiLabelPosition = $state('bottom-right');
let legalOpen = $state(null); // 'imprint' | 'privacy' | null
// The file entry whose crop is being adjusted, or null. Owned here (not in
// Dropzone) so CropModal renders at the page root, escaping the animate-fade-up
Expand Down Expand Up @@ -70,6 +75,11 @@
for (const entry of files) form.append('files', entry.file);
form.append('focals', JSON.stringify(focals));
for (const name of selectedPresets) form.append('presets', name);
if (aiLabelEnabled) {
form.append('aiLabel', aiLabelStyle);
form.append('aiLabelColor', aiLabelColor);
form.append('aiLabelPosition', aiLabelPosition);
}

let jobId;
try {
Expand All @@ -91,6 +101,10 @@
progress.reset();
files = [];
selectedPresets = [];
aiLabelEnabled = false;
aiLabelStyle = 'generated';
aiLabelColor = 'black';
aiLabelPosition = 'bottom-right';
cropping = null;
autoDownloaded = false;
}
Expand Down Expand Up @@ -158,11 +172,19 @@
<div class="animate-fade-up" style="animation-delay: 180ms">
<PresetSelector bind:selected={selectedPresets} />
</div>
<div class="animate-fade-up" style="animation-delay: 210ms">
<AILabelOptions
bind:enabled={aiLabelEnabled}
bind:style={aiLabelStyle}
bind:color={aiLabelColor}
bind:position={aiLabelPosition}
/>
</div>

<button
type="button"
class="group relative overflow-hidden rounded-lg bg-gradient-to-r from-ctp-mauve via-ctp-lavender to-ctp-blue bg-[length:200%_auto] px-4 py-4 text-center text-lg font-semibold text-ctp-base shadow-lg shadow-ctp-mauve/20 transition-all duration-300 hover:bg-right hover:shadow-xl hover:shadow-ctp-mauve/40 enabled:hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none animate-fade-up"
style="animation-delay: 240ms"
style="animation-delay: 270ms"
disabled={!canSubmit}
onclick={submit}
>
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/components/AILabelOptions.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script>
let {
enabled = $bindable(false),
style = $bindable('generated'),
color = $bindable('black'),
position = $bindable('bottom-right'),
} = $props();

const styles = [
{ value: 'generated', label: 'AI generated' },
{ value: 'modified', label: 'AI modified' },
{ value: 'ai', label: 'AI icon' },
];
const positions = [
{ value: 'top-left', label: 'Top left' },
{ value: 'top-right', label: 'Top right' },
{ value: 'bottom-left', label: 'Bottom left' },
{ value: 'bottom-right', label: 'Bottom right' },
];
</script>

<section class="overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-base transition-colors {enabled ? 'border-ctp-teal/70' : ''}">
<label class="flex cursor-pointer items-center gap-4 px-4 py-4 sm:px-5">
<input class="peer sr-only" type="checkbox" bind:checked={enabled} />
<span
class="relative h-7 w-12 flex-none rounded-full bg-ctp-surface1 transition-colors peer-checked:bg-ctp-teal peer-focus-visible:ring-2 peer-focus-visible:ring-ctp-teal peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-ctp-base after:absolute after:left-1 after:top-1 after:h-5 after:w-5 after:rounded-full after:bg-ctp-text after:transition-transform peer-checked:after:translate-x-5"
aria-hidden="true"
></span>
<span class="min-w-0">
<span class="block font-semibold text-ctp-text">Add an AI label</span>
<span class="block text-sm text-ctp-subtext1">
Add a visible disclosure to regular image outputs. Favicons and PDFs are left unchanged.
</span>
</span>
</label>

{#if enabled}
<div class="grid gap-5 border-t border-ctp-surface1 bg-ctp-surface0/35 px-4 py-5 sm:grid-cols-[1fr_auto] sm:px-5">
<div class="flex min-w-0 flex-col gap-5">
<fieldset>
<legend class="mb-2 text-xs font-semibold tracking-wide text-ctp-teal uppercase">Label</legend>
<div class="flex flex-wrap gap-2">
{#each styles as option (option.value)}
<label class="cursor-pointer">
<input class="peer sr-only" type="radio" bind:group={style} value={option.value} />
<span class="block rounded-lg border border-ctp-surface1 bg-ctp-base px-3 py-2 text-sm text-ctp-subtext1 transition-all peer-checked:border-ctp-teal peer-checked:bg-ctp-teal/10 peer-checked:text-ctp-text peer-focus-visible:ring-2 peer-focus-visible:ring-ctp-teal">
{option.label}
</span>
</label>
{/each}
</div>
</fieldset>

<div class="grid gap-5 sm:grid-cols-2">
<fieldset>
<legend class="mb-2 text-xs font-semibold tracking-wide text-ctp-teal uppercase">Color</legend>
<div class="flex gap-2">
{#each ['black', 'white'] as option}
<label class="cursor-pointer">
<input class="peer sr-only" type="radio" bind:group={color} value={option} />
<span class="flex items-center gap-2 rounded-lg border border-ctp-surface1 bg-ctp-base px-3 py-2 text-sm capitalize text-ctp-subtext1 transition-all peer-checked:border-ctp-teal peer-checked:text-ctp-text peer-focus-visible:ring-2 peer-focus-visible:ring-ctp-teal">
<span class="h-3.5 w-3.5 rounded-full border border-ctp-overlay0 {option === 'black' ? 'bg-black' : 'bg-white'}"></span>
{option}
</span>
</label>
{/each}
</div>
</fieldset>

<fieldset>
<legend class="mb-2 text-xs font-semibold tracking-wide text-ctp-teal uppercase">Position</legend>
<div class="grid w-fit grid-cols-2 gap-1.5" aria-label="Label position">
{#each positions as option}
<label class="cursor-pointer" title={option.label}>
<input class="peer sr-only" type="radio" bind:group={position} value={option.value} />
<span class="relative block h-8 w-10 rounded-md border border-ctp-surface1 bg-ctp-base transition-all peer-checked:border-ctp-teal peer-checked:bg-ctp-teal/15 peer-focus-visible:ring-2 peer-focus-visible:ring-ctp-teal">
<span class="absolute h-2 w-3 rounded-sm {position === option.value ? 'bg-ctp-teal' : 'bg-ctp-overlay0'} {option.value.includes('top') ? 'top-1' : 'bottom-1'} {option.value.includes('left') ? 'left-1' : 'right-1'}"></span>
<span class="sr-only">{option.label}</span>
</span>
</label>
{/each}
</div>
</fieldset>
</div>
</div>

<div class="flex items-center justify-center sm:w-56">
<div class="relative aspect-[4/3] w-full max-w-56 overflow-hidden rounded-lg border border-ctp-surface1 bg-gradient-to-br from-ctp-blue/70 via-ctp-lavender/60 to-ctp-pink/70 shadow-inner">
<img
src="/stamps/{style}-{color}.png"
alt="Selected AI label preview"
class="absolute max-h-[18%] max-w-[42%] object-contain {position.includes('top') ? 'top-[4%]' : 'bottom-[4%]'} {position.includes('left') ? 'left-[4%]' : 'right-[4%]'}"
/>
</div>
</div>
</div>
{/if}
</section>
3 changes: 2 additions & 1 deletion handlers/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ func (j *Job) Finished() bool {
// over ALL files (one progress unit per bundle preset). It runs in its own
// goroutine off the upload request and therefore uses context.Background(): the
// request context is canceled the moment the upload handler returns.
func runJob(job *Job, files []srcFile, imagePresets, bundlePresets []processor.Preset) {
func runJob(job *Job, files []srcFile, imagePresets, bundlePresets []processor.Preset, aiLabel processor.AILabel) {
// --- Per-file phase: each file through every per-image preset. ---
for _, f := range files {
f := f
Expand All @@ -348,6 +348,7 @@ func runJob(job *Job, files []srcFile, imagePresets, bundlePresets []processor.P
copy(filePresets, imagePresets)
for i := range filePresets {
filePresets[i].Focal = f.focal
filePresets[i].AILabel = aiLabel
}
_, err := processor.ProcessStream(context.Background(), f.data, filePresets,
func(_ int, r processor.Result) {
Expand Down
40 changes: 39 additions & 1 deletion handlers/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func Upload(store *Store, maxFileBytes int64) fiber.Handler {
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
aiLabel, err := parseAILabel(form.Value)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}

files := make([]srcFile, 0, len(headers))
for i, fh := range headers {
Expand All @@ -95,12 +99,46 @@ func Upload(store *Store, maxFileBytes int64) fiber.Handler {

job := store.Create(total)
// Track the goroutine so graceful shutdown can drain in-flight jobs.
store.Go(func() { runJob(job, files, imagePresets, bundlePresets) })
store.Go(func() { runJob(job, files, imagePresets, bundlePresets, aiLabel) })

return c.JSON(fiber.Map{"jobId": job.ID})
}
}

func parseAILabel(values map[string][]string) (processor.AILabel, error) {
style := firstValue(values["aiLabel"])
if style == "" {
return processor.AILabel{}, nil
}
color := firstValue(values["aiLabelColor"])
position := firstValue(values["aiLabelPosition"])
if color == "" {
color = "black"
}
if position == "" {
position = "bottom-right"
}
if style != "ai" && style != "generated" && style != "modified" {
return processor.AILabel{}, fmt.Errorf("invalid AI label %q", style)
}
if color != "black" && color != "white" {
return processor.AILabel{}, fmt.Errorf("invalid AI label color %q", color)
}
switch position {
case "top-left", "top-right", "bottom-left", "bottom-right":
default:
return processor.AILabel{}, fmt.Errorf("invalid AI label position %q", position)
}
return processor.AILabel{Style: style, Color: color, Position: position}, nil
}

func firstValue(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}

// partitionPresets splits resolved presets into per-image presets (run once per
// file) and bundle presets (run once over all files), preserving order within
// each group.
Expand Down
43 changes: 43 additions & 0 deletions handlers/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,46 @@ func TestParseFocals(t *testing.T) {
}
})
}

func TestParseAILabel(t *testing.T) {
t.Run("absent disables label", func(t *testing.T) {
got, err := parseAILabel(nil)
if err != nil || got.Enabled() {
t.Fatalf("got %+v, err %v; want disabled", got, err)
}
})

t.Run("parses all options", func(t *testing.T) {
got, err := parseAILabel(map[string][]string{
"aiLabel": {"modified"},
"aiLabelColor": {"white"},
"aiLabelPosition": {"top-left"},
})
if err != nil {
t.Fatal(err)
}
if got.Style != "modified" || got.Color != "white" || got.Position != "top-left" {
t.Fatalf("got %+v", got)
}
})

t.Run("defaults optional choices", func(t *testing.T) {
got, err := parseAILabel(map[string][]string{"aiLabel": {"generated"}})
if err != nil {
t.Fatal(err)
}
if got.Color != "black" || got.Position != "bottom-right" {
t.Fatalf("got %+v", got)
}
})

for _, values := range []map[string][]string{
{"aiLabel": {"other"}},
{"aiLabel": {"ai"}, "aiLabelColor": {"red"}},
{"aiLabel": {"ai"}, "aiLabelPosition": {"center"}},
} {
if _, err := parseAILabel(values); err == nil {
t.Errorf("expected error for %v", values)
}
}
}
63 changes: 63 additions & 0 deletions processor/ai_label.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package processor

// AILabel describes an optional visible AI disclosure composited onto image
// outputs. It contains plain data so non-vips builds can parse and test it.
type AILabel struct {
Style string
Color string
Position string
}

// Enabled reports whether a label was selected. Upload validation guarantees
// the remaining fields are valid whenever Style is non-empty.
func (l AILabel) Enabled() bool {
return l.Style != ""
}

// aiLabelPlacement returns a scaled stamp size and its top-left output
// coordinate. Labels target 9% of the short edge, never exceed 42% of the image
// width, and retain a 2.5% inset so they remain useful across preset sizes.
func aiLabelPlacement(imageW, imageH, stampW, stampH int, position string) (x, y, width, height int) {
if imageW <= 0 || imageH <= 0 || stampW <= 0 || stampH <= 0 {
return 0, 0, 0, 0
}

short := imageW
if imageH < short {
short = imageH
}
margin := clampInt(roundInt(float64(short)*0.025), 1, 48)
targetH := clampInt(roundInt(float64(short)*0.09), 16, 120)

width = roundInt(float64(stampW) * float64(targetH) / float64(stampH))
height = targetH
maxW := clampInt(roundInt(float64(imageW)*0.42), 1, imageW-2*margin)
if width > maxW {
width = maxW
height = roundInt(float64(stampH) * float64(width) / float64(stampW))
}
maxH := imageH - 2*margin
if height > maxH {
height = maxH
width = roundInt(float64(stampW) * float64(height) / float64(stampH))
}
if width < 1 || height < 1 {
return 0, 0, 0, 0
}

x, y = margin, margin
if position == "top-right" || position == "bottom-right" {
x = imageW - margin - width
}
if position == "bottom-left" || position == "bottom-right" {
y = imageH - margin - height
}
return x, y, width, height
}

func roundInt(v float64) int {
if v < 0 {
return int(v - 0.5)
}
return int(v + 0.5)
}
32 changes: 32 additions & 0 deletions processor/ai_label_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package processor

import "testing"

func TestAILabelPlacement(t *testing.T) {
tests := []struct {
name string
position string
wantX int
wantY int
}{
{name: "top left", position: "top-left", wantX: 20, wantY: 20},
{name: "top right", position: "top-right", wantX: 764, wantY: 20},
{name: "bottom left", position: "bottom-left", wantX: 20, wantY: 708},
{name: "bottom right", position: "bottom-right", wantX: 764, wantY: 708},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
x, y, w, h := aiLabelPlacement(1000, 800, 300, 100, tt.position)
if x != tt.wantX || y != tt.wantY || w != 216 || h != 72 {
t.Fatalf("got (%d,%d) %dx%d, want (%d,%d) 216x72", x, y, w, h, tt.wantX, tt.wantY)
}
})
}
}

func TestAILabelPlacementCapsWideLabel(t *testing.T) {
x, y, w, h := aiLabelPlacement(200, 100, 7459, 2363, "bottom-right")
if x != 146 || y != 81 || w != 51 || h != 16 {
t.Fatalf("got (%d,%d) %dx%d, want (146,81) 51x16", x, y, w, h)
}
}
Loading
Loading