This commit is contained in:
+56
-4
@@ -249,6 +249,7 @@ func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath s
|
|||||||
func (a *App) searchMedia(c *gin.Context) {
|
func (a *App) searchMedia(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
|
Platforms []string `json:"platforms"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -265,8 +266,9 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
queryVariants = []string{req.Query}
|
queryVariants = []string{req.Query}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enabledPlatforms := normalizePlatforms(req.Platforms)
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching Google Video, Envato, and Artgrid", "progress": 35})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching Google Video, Envato, and Artgrid", "progress": 35})
|
||||||
results, err := a.SearchService.SearchMedia(queryVariants)
|
results, err := a.SearchService.SearchMedia(queryVariants, enabledPlatforms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()})
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -285,9 +287,12 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ")
|
rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ")
|
||||||
}
|
}
|
||||||
scored := rankSearchResults(rankQuery, results)
|
scored := rankSearchResults(rankQuery, results)
|
||||||
shortlist := scored[:min(len(scored), 10)]
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing shortlisted thumbnails with Gemini Vision", "progress": 75})
|
recommended := evaluateAllCandidatesWithGemini(a.GeminiService, req.Query, scored)
|
||||||
recommended, err := a.GeminiService.Recommend(req.Query, shortlist)
|
err = nil
|
||||||
|
if len(recommended) == 0 {
|
||||||
|
err = fmt.Errorf("gemini vision returned no recommended items across all candidate batches")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fallback := make([]services.AIRecommendation, 0, min(20, len(scored)))
|
fallback := make([]services.AIRecommendation, 0, min(20, len(scored)))
|
||||||
for _, result := range scored[:min(20, len(scored))] {
|
for _, result := range scored[:min(20, len(scored))] {
|
||||||
@@ -345,6 +350,53 @@ func min(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePlatforms(platforms []string) map[string]bool {
|
||||||
|
if len(platforms) == 0 {
|
||||||
|
return map[string]bool{
|
||||||
|
"envato": true,
|
||||||
|
"artgrid": true,
|
||||||
|
"google video": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized := map[string]bool{}
|
||||||
|
for _, item := range platforms {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(item)) {
|
||||||
|
case "envato":
|
||||||
|
normalized["envato"] = true
|
||||||
|
case "artgrid":
|
||||||
|
normalized["artgrid"] = true
|
||||||
|
case "google video", "google_video", "google":
|
||||||
|
normalized["google video"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateAllCandidatesWithGemini(service *services.GeminiService, query string, ranked []services.SearchResult) []services.AIRecommendation {
|
||||||
|
const chunkSize = 8
|
||||||
|
merged := make([]services.AIRecommendation, 0, len(ranked))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for start := 0; start < len(ranked); start += chunkSize {
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(ranked) {
|
||||||
|
end = len(ranked)
|
||||||
|
}
|
||||||
|
batch := ranked[start:end]
|
||||||
|
recommended, err := service.Recommend(query, batch)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range recommended {
|
||||||
|
if item.Link == "" || seen[item.Link] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Link] = true
|
||||||
|
merged = append(merged, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
func rankSearchResults(query string, results []services.SearchResult) []services.SearchResult {
|
func rankSearchResults(query string, results []services.SearchResult) []services.SearchResult {
|
||||||
queryTerms := strings.Fields(strings.ToLower(query))
|
queryTerms := strings.Fields(strings.ToLower(query))
|
||||||
positiveTerms := []string{
|
positiveTerms := []string{
|
||||||
|
|||||||
+25
-7
@@ -45,7 +45,7 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
|
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, error) {
|
||||||
if s.BaseURL == "" {
|
if s.BaseURL == "" {
|
||||||
return nil, fmt.Errorf("searxng base url is not configured")
|
return nil, fmt.Errorf("searxng base url is not configured")
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,9 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
|
if len(enabledPlatforms) > 0 && !enabledPlatforms[strings.ToLower(source.name)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, searchQuery := range source.build(base) {
|
for _, searchQuery := range source.build(base) {
|
||||||
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
|
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,6 +208,9 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
|
|||||||
extractMetaContent(html, "og:image"),
|
extractMetaContent(html, "og:image"),
|
||||||
extractMetaContent(html, "twitter:image"),
|
extractMetaContent(html, "twitter:image"),
|
||||||
)
|
)
|
||||||
|
if result.ThumbnailURL == "" {
|
||||||
|
result.ThumbnailURL = extractArtgridBackgroundThumbnail(html, clipID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if result.PreviewVideoURL == "" {
|
if result.PreviewVideoURL == "" {
|
||||||
result.PreviewVideoURL = extractVideoPreviewURL(html)
|
result.PreviewVideoURL = extractVideoPreviewURL(html)
|
||||||
@@ -283,7 +289,7 @@ func buildGoogleVideoQueries(base string) []string {
|
|||||||
func buildEnvatoQueries(base string) []string {
|
func buildEnvatoQueries(base string) []string {
|
||||||
return []string{
|
return []string{
|
||||||
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com`, base),
|
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com`, base),
|
||||||
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:videohive.net/item`, base),
|
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com/stock-video`, base),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +301,10 @@ func buildArtgridQueries(base string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isUsefulGoogleVideoResult(result SearchResult) bool {
|
func isUsefulGoogleVideoResult(result SearchResult) bool {
|
||||||
|
lowerLink := strings.ToLower(result.Link)
|
||||||
|
if !(strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/") || strings.Contains(lowerLink, "youtube.com/shorts/")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
text := strings.ToLower(result.Title + " " + result.Snippet)
|
text := strings.ToLower(result.Title + " " + result.Snippet)
|
||||||
for _, banned := range []string{
|
for _, banned := range []string{
|
||||||
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
|
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
|
||||||
@@ -315,11 +325,8 @@ func isRenderableEnvatoResult(result SearchResult) bool {
|
|||||||
}
|
}
|
||||||
host := strings.ToLower(parsed.Host)
|
host := strings.ToLower(parsed.Host)
|
||||||
path := strings.Trim(parsed.Path, "/")
|
path := strings.Trim(parsed.Path, "/")
|
||||||
if strings.Contains(host, "videohive.net") {
|
|
||||||
return strings.HasPrefix(path, "item/") && len(strings.Split(path, "/")) >= 2
|
|
||||||
}
|
|
||||||
if strings.Contains(host, "elements.envato.com") {
|
if strings.Contains(host, "elements.envato.com") {
|
||||||
if path == "" || strings.Contains(path, "/") {
|
if path == "" || strings.Contains(path, "/stock-video") || strings.Contains(path, "/video-templates") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path)
|
return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path)
|
||||||
@@ -407,13 +414,24 @@ func extractVideoPreviewURL(html string) string {
|
|||||||
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||||
candidate = strings.ReplaceAll(candidate, `\u002F`, `/`)
|
candidate = strings.ReplaceAll(candidate, `\u002F`, `/`)
|
||||||
candidate = strings.ReplaceAll(candidate, `\\`, "")
|
candidate = strings.ReplaceAll(candidate, `\\`, "")
|
||||||
if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") {
|
if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") || strings.Contains(strings.ToLower(candidate), "watermark") {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractArtgridBackgroundThumbnail(html, clipID string) string {
|
||||||
|
pattern := regexp.MustCompile(`https://[^"'\\s>]+(?:artgrid\.imgix\.net|cms-public-artifacts\.artlist\.io|artlist-content-images\.imgix\.net)[^"'\\s>]+(?:jpeg|jpg|png|webp)`)
|
||||||
|
matches := pattern.FindAllString(html, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
if strings.Contains(match, clipID) || strings.Contains(strings.ToLower(match), "graded-thumbnail") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func extractArtgridClipID(link string) string {
|
func extractArtgridClipID(link string) string {
|
||||||
matches := regexp.MustCompile(`/clip/([0-9]+)/`).FindStringSubmatch(link)
|
matches := regexp.MustCompile(`/clip/([0-9]+)/`).FindStringSubmatch(link)
|
||||||
if len(matches) == 2 {
|
if len(matches) == 2 {
|
||||||
|
|||||||
+31
-3
@@ -5,6 +5,7 @@ const searchQuery = document.getElementById("searchQuery");
|
|||||||
const searchResults = document.getElementById("searchResults");
|
const searchResults = document.getElementById("searchResults");
|
||||||
const searchWarning = document.getElementById("searchWarning");
|
const searchWarning = document.getElementById("searchWarning");
|
||||||
const queryVariants = document.getElementById("queryVariants");
|
const queryVariants = document.getElementById("queryVariants");
|
||||||
|
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
|
||||||
const dropzone = document.getElementById("dropzone");
|
const dropzone = document.getElementById("dropzone");
|
||||||
const fileInput = document.getElementById("fileInput");
|
const fileInput = document.getElementById("fileInput");
|
||||||
const uploadResult = document.getElementById("uploadResult");
|
const uploadResult = document.getElementById("uploadResult");
|
||||||
@@ -35,6 +36,7 @@ let cropStart = 0;
|
|||||||
let cropEnd = 0;
|
let cropEnd = 0;
|
||||||
let cropMax = 0;
|
let cropMax = 0;
|
||||||
let activeThumb = null;
|
let activeThumb = null;
|
||||||
|
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
|
||||||
|
|
||||||
function setStatus(label, progress) {
|
function setStatus(label, progress) {
|
||||||
statusLabel.textContent = label;
|
statusLabel.textContent = label;
|
||||||
@@ -87,6 +89,19 @@ function renderQueryVariants(queries = []) {
|
|||||||
queryVariants.classList.remove("hidden");
|
queryVariants.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncPlatformButtons() {
|
||||||
|
for (const button of platformToggles) {
|
||||||
|
const platform = button.dataset.platformToggle;
|
||||||
|
const active = activePlatforms.has(platform);
|
||||||
|
button.classList.toggle("bg-white", active);
|
||||||
|
button.classList.toggle("text-black", active);
|
||||||
|
button.classList.toggle("border-white", active);
|
||||||
|
button.classList.toggle("bg-transparent", !active);
|
||||||
|
button.classList.toggle("text-zinc-300", !active);
|
||||||
|
button.classList.toggle("border-white/20", !active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectWS() {
|
function connectWS() {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
|
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
|
||||||
@@ -145,12 +160,13 @@ function renderResults(results) {
|
|||||||
if (item.previewVideoUrl) {
|
if (item.previewVideoUrl) {
|
||||||
previewVideo.src = item.previewVideoUrl;
|
previewVideo.src = item.previewVideoUrl;
|
||||||
previewVideo.poster = item.thumbnailUrl || "";
|
previewVideo.poster = item.thumbnailUrl || "";
|
||||||
node.addEventListener("mouseenter", () => {
|
const mediaArea = node.querySelector(".relative");
|
||||||
|
mediaArea.addEventListener("mouseenter", () => {
|
||||||
overlays.forEach((overlay) => overlay.classList.add("hidden"));
|
overlays.forEach((overlay) => overlay.classList.add("hidden"));
|
||||||
previewVideo.classList.remove("hidden");
|
previewVideo.classList.remove("hidden");
|
||||||
previewVideo.play().catch(() => {});
|
previewVideo.play().catch(() => {});
|
||||||
});
|
});
|
||||||
node.addEventListener("mouseleave", () => {
|
mediaArea.addEventListener("mouseleave", () => {
|
||||||
previewVideo.pause();
|
previewVideo.pause();
|
||||||
previewVideo.currentTime = 0;
|
previewVideo.currentTime = 0;
|
||||||
previewVideo.classList.add("hidden");
|
previewVideo.classList.add("hidden");
|
||||||
@@ -169,7 +185,7 @@ searchForm.addEventListener("submit", async (event) => {
|
|||||||
const data = await api("/api/search", {
|
const data = await api("/api/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query: searchQuery.value }),
|
body: JSON.stringify({ query: searchQuery.value, platforms: Array.from(activePlatforms) }),
|
||||||
});
|
});
|
||||||
renderResults(data.results || []);
|
renderResults(data.results || []);
|
||||||
renderQueryVariants(data.queries || []);
|
renderQueryVariants(data.queries || []);
|
||||||
@@ -368,6 +384,18 @@ previewThumbnail.addEventListener("load", () => {
|
|||||||
previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`;
|
previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (const button of platformToggles) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const platform = button.dataset.platformToggle;
|
||||||
|
if (activePlatforms.has(platform) && activePlatforms.size > 1) {
|
||||||
|
activePlatforms.delete(platform);
|
||||||
|
} else {
|
||||||
|
activePlatforms.add(platform);
|
||||||
|
}
|
||||||
|
syncPlatformButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
|
syncPlatformButtons();
|
||||||
setStatus("idle", 0);
|
setStatus("idle", 0);
|
||||||
|
|||||||
+6
-1
@@ -35,6 +35,11 @@
|
|||||||
<h2 class="text-2xl font-semibold text-white">AI Smart Discovery</h2>
|
<h2 class="text-2xl font-semibold text-white">AI Smart Discovery</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-4 flex flex-wrap gap-3">
|
||||||
|
<button data-platform-toggle="envato" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Envato</button>
|
||||||
|
<button data-platform-toggle="artgrid" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Artgrid</button>
|
||||||
|
<button data-platform-toggle="google video" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Google Video</button>
|
||||||
|
</div>
|
||||||
<form id="searchForm" class="flex flex-col gap-3 md:flex-row">
|
<form id="searchForm" class="flex flex-col gap-3 md:flex-row">
|
||||||
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
|
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
|
||||||
<button class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
<button class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
|
||||||
@@ -139,6 +144,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/app.js?v=20260313f" defer></script>
|
<script src="/app.js?v=20260313g" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user