diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 79878b4..c1cafa4 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -65,43 +65,48 @@ User query: ` + query, }, } - rawBody, _ := json.Marshal(body) - endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey - resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody)) + rawText, err := g.generateText(body) if err != nil { - return []string{query}, fmt.Errorf("gemini query expansion request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return []string{query}, fmt.Errorf("gemini query expansion returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + return []string{query}, err } - var payload struct { - Candidates []struct { - Content struct { - Parts []struct { - Text string `json:"text"` - } `json:"parts"` - } `json:"content"` - } `json:"candidates"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return []string{query}, fmt.Errorf("gemini query expansion response decode failed: %w", err) - } - if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 { - return []string{query}, fmt.Errorf("gemini query expansion returned no candidates") - } - - jsonText, err := extractJSONObject(payload.Candidates[0].Content.Parts[0].Text) + jsonText, err := extractJSONObject(rawText) if err != nil { - return []string{query}, fmt.Errorf("gemini query expansion JSON extraction failed: %w", err) + strictBody := map[string]any{ + "contents": []map[string]any{ + { + "parts": []map[string]string{ + { + "text": `STRICT JSON ONLY. +Output must start with { and end with }. +Do not add prose, explanations, markdown, code fences, or labels. +Return exactly this shape: {"querywords":["..."]}. +Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid. +If the original query is Korean, include strong English stock-footage search phrases. +User query: ` + query, + }, + }, + }, + }, + "generationConfig": map[string]any{ + "responseMimeType": "application/json", + "temperature": 0.1, + "maxOutputTokens": 220, + }, + } + rawText, retryErr := g.generateText(strictBody) + if retryErr != nil { + return []string{query}, retryErr + } + jsonText, err = extractJSONObject(rawText) + if err != nil { + return []string{query}, fmt.Errorf("gemini query expansion JSON extraction failed after strict retry: %w", err) + } } var parsed QueryExpansion if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { - return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w; raw=%q", err, truncateForError(payload.Candidates[0].Content.Parts[0].Text, 200)) + return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w; raw=%q", err, truncateForError(rawText, 200)) } queries := []string{query} @@ -121,6 +126,38 @@ User query: ` + query, return queries, nil } +func (g *GeminiService) generateText(body map[string]any) (string, error) { + rawBody, _ := json.Marshal(body) + endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey + resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody)) + if err != nil { + return "", fmt.Errorf("gemini request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("gemini returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + + var payload struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("gemini response decode failed: %w", err) + } + if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("gemini returned no candidates") + } + return payload.Candidates[0].Content.Parts[0].Text, nil +} + func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AIRecommendation, error) { if g.APIKey == "" { return nil, fmt.Errorf("gemini api key is not configured") diff --git a/frontend/app.js b/frontend/app.js index a6df659..3e64769 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -21,13 +21,20 @@ const previewDuration = document.getElementById("previewDuration"); const qualitySelect = document.getElementById("qualitySelect"); const confirmDownload = document.getElementById("confirmDownload"); const closePreviewModal = document.getElementById("closePreviewModal"); -const startRange = document.getElementById("startRange"); -const endRange = document.getElementById("endRange"); const rangeSummary = document.getElementById("rangeSummary"); +const rangeFill = document.getElementById("rangeFill"); +const startThumb = document.getElementById("startThumb"); +const endThumb = document.getElementById("endThumb"); +const startLabel = document.getElementById("startLabel"); +const endLabel = document.getElementById("endLabel"); const setStartFromPreview = document.getElementById("setStartFromPreview"); const setEndFromPreview = document.getElementById("setEndFromPreview"); let pendingDownload = null; +let cropStart = 0; +let cropEnd = 0; +let cropMax = 0; +let activeThumb = null; function setStatus(label, progress) { statusLabel.textContent = label; @@ -43,18 +50,26 @@ function toClock(totalSeconds) { } function syncRanges() { - let start = Number(startRange.value || 0); - let end = Number(endRange.value || 0); + let start = cropStart; + let end = cropEnd; if (start > end) { - if (document.activeElement === startRange) { + if (activeThumb === "start") { end = start; - endRange.value = String(end); } else { start = end; - startRange.value = String(start); } } + cropStart = start; + cropEnd = end; + const startPct = cropMax > 0 ? (cropStart / cropMax) * 100 : 0; + const endPct = cropMax > 0 ? (cropEnd / cropMax) * 100 : 0; + startThumb.style.left = `calc(${startPct}% - 10px)`; + endThumb.style.left = `calc(${endPct}% - 10px)`; + rangeFill.style.left = `${startPct}%`; + rangeFill.style.width = `${Math.max(0, endPct - startPct)}%`; rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`; + startLabel.textContent = `Start ${toClock(start)}`; + endLabel.textContent = `End ${toClock(end)}`; } function renderQueryVariants(queries = []) { @@ -188,11 +203,9 @@ function openPreviewModal(preview) { option.textContent = item.label; qualitySelect.appendChild(option); } - const maxDuration = Number(preview.durationSeconds || 0); - startRange.max = String(maxDuration); - endRange.max = String(maxDuration); - startRange.value = "0"; - endRange.value = String(maxDuration); + cropMax = Number(preview.durationSeconds || 0); + cropStart = 0; + cropEnd = cropMax; syncRanges(); previewModal.classList.remove("hidden"); previewModal.classList.add("flex"); @@ -205,8 +218,9 @@ function closeModal() { previewMediaFrame.style.aspectRatio = ""; previewModal.classList.add("hidden"); previewModal.classList.remove("flex"); - startRange.value = "0"; - endRange.value = "0"; + cropStart = 0; + cropEnd = 0; + cropMax = 0; syncRanges(); pendingDownload = null; } @@ -273,8 +287,8 @@ confirmDownload.addEventListener("click", async () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: pendingDownload.url, - start: toClock(startRange.value), - end: toClock(endRange.value), + start: toClock(cropStart), + end: toClock(cropEnd), quality: qualitySelect.value, force: pendingDownload.force, }), @@ -292,16 +306,40 @@ previewModal.addEventListener("click", (event) => { closeModal(); } }); -startRange.addEventListener("input", syncRanges); -endRange.addEventListener("input", syncRanges); setStartFromPreview.addEventListener("click", () => { - startRange.value = String(Math.floor(previewVideo.currentTime || 0)); + cropStart = Math.floor(previewVideo.currentTime || 0); + activeThumb = "start"; syncRanges(); }); setEndFromPreview.addEventListener("click", () => { - endRange.value = String(Math.floor(previewVideo.currentTime || 0)); + cropEnd = Math.floor(previewVideo.currentTime || 0); + activeThumb = "end"; syncRanges(); }); +for (const [thumb, name] of [[startThumb, "start"], [endThumb, "end"]]) { + thumb.addEventListener("pointerdown", (event) => { + event.preventDefault(); + activeThumb = name; + thumb.setPointerCapture(event.pointerId); + }); + thumb.addEventListener("pointermove", (event) => { + if (activeThumb !== name || cropMax <= 0) { + return; + } + const track = thumb.parentElement.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (event.clientX - track.left) / track.width)); + const value = Math.round(ratio * cropMax); + if (name === "start") { + cropStart = value; + } else { + cropEnd = value; + } + syncRanges(); + }); + thumb.addEventListener("pointerup", () => { + activeThumb = null; + }); +} previewVideo.addEventListener("loadedmetadata", () => { if (previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { previewMediaFrame.style.aspectRatio = `${previewVideo.videoWidth} / ${previewVideo.videoHeight}`; diff --git a/frontend/index.html b/frontend/index.html index bc38159..ddc5e89 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -96,14 +96,16 @@ 00:00:00 - 00:00:00
- - +
+
+
+ + +
+
+ Start 00:00:00 + End 00:00:00 +
diff --git a/frontend/style.css b/frontend/style.css index d4d3357..8239a5d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -26,34 +26,8 @@ body { -webkit-line-clamp: 3; } -.slider-thumb::-webkit-slider-thumb { - -webkit-appearance: none; - height: 18px; - width: 18px; - border-radius: 9999px; - border: 2px solid #09090b; - background: #fafafa; - cursor: pointer; - margin-top: -7px; -} - -.slider-thumb::-moz-range-thumb { - height: 18px; - width: 18px; - border-radius: 9999px; - border: 2px solid #09090b; - background: #fafafa; - cursor: pointer; -} - -.slider-thumb::-webkit-slider-runnable-track { - height: 4px; - border-radius: 9999px; - background: rgba(255, 255, 255, 0.18); -} - -.slider-thumb::-moz-range-track { - height: 4px; - border-radius: 9999px; - background: rgba(255, 255, 255, 0.18); +.dual-slider__thumb { + touch-action: none; + cursor: ew-resize; + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08); }